[TIL-0413] 인스타그램 게시물 스크래핑 과정

jiny·2026년 4월 13일

AI Agent 실습

목록 보기
16/21

🌟 개요

🤔 인스타그램 스크래핑 계기

인플루언서 매칭 플랫폼을 개발하면서 한 가지 고민이 생겼다.

광고주 입장에서 인플루언서를 선정할 때, 단순히 팔로워 수나 카테고리만 볼 수는 없다. 그 인플루언서가 평소에 어떤 분위기의 콘텐츠를 올리는지, 광고를 얼마나 자주 하는지, 팔로워들이 광고 게시글에 어떻게 반응하는지와 같은 맥락이 있어야 진짜 의미 있는 매칭이 가능하다.

그래서 단순 프로필 정보 외에 실제 피드 데이터를 직접 수집하고 분석해보기로 했다. 비광고 게시글에서는 전반적인 톤과 스타일, 광고 게시글에서는 광고 유형과 팔로워들의 반응을 중심으로 분석하기로 하고, 이를 위해 인스타그램 스크래핑을 시작하게 됐다.

얼핏 생각하면 인스타그램 URL에 접속해서 HTML을 가져오고 파싱하면 될 줄 알았지만, 몇 가지 문제가 있었다.

🚨 문제 1: 인스타그램은 SPA이다

인스타그램은 React 기반의 Single Page Application(SPA)이다. 브라우저가 페이지를 처음 로드할 때 서버에서 내려주는 HTML은 사실상 빈 껍데기에 가깝다. 실제 콘텐츠 대신 React 앱을 구동하는 번들링된 JavaScript 파일들만 잔뜩 포함되어 있다.

실제 게시글 내용(캡션, 좋아요 수, 댓글 등)은 이 JavaScript가 실행된 후, API를 호출해 동적으로 DOM에 삽입된다. 즉, JS가 실행되기 전에는 긁어갈 콘텐츠 자체가 존재하지 않는다.

🚨 문제 2: instaloader의 한계

instaloader는 Python에서 인스타그램 데이터를 가져오는 데 가장 널리 알려진 오픈소스 라이브러리이다. 브라우저 없이 인스타그램의 내부 API를 직접 호출하는 방식으로, 기본 코드는 다음과 같다.

import instaloader

loader = instaloader.Instaloader()
profile = instaloader.Profile.from_username(loader.context, "target_username")

for post in profile.get_posts():
   print(post.caption, post.likes)

정상적으로 잘 동작할거라고 생각했지만, 문제가 생겼다.

인스타그램은 브라우저가 아닌 곳에서 오는 API 요청 패턴을 감지한다. 헤더, 요청 주기, User-Agent 등을 분석해 비정상적인 접근으로 판단하면 응답을 차단하거나 계정에 보안 인증을 요구한다. 로그인을 해도 마찬가지였다.
세션을 저장해서 재사용하는 방법까지 시도했지만, 결국 일정 시간이 지나면 다시 막혔다.

instaloader 자체의 문제라기보다는, 인스타그램이 비공식 API 접근을 지속적으로 막고 있기 때문이다. 실제로 instaloader GitHub 이슈를 보면 같은 문제를 겪는 사람들이 꾸준히 올라온다.

🚨 문제 3: requests로는 빈 껍데기만 온다

instaloader가 실패하고 나서, 이번엔 직접 HTTP 요청을 보내고 응답을 파싱하는 방식을 시도했다. Python 스크래핑의 가장 기본 조합인 requests + BeautifulSoup였다.

파이썬 스크래핑의 기본 코드는 다음과 같다.

import results
from bs4 import BeautifulSoup

url = "https://www.instagram.com/p/ABC123/"
res = requests.get(url, headers={"User-Agent": "Mozilla/5.0 ..."})
soup = BeautifulSoup(res.text, "html.parser")

print(soup.find("meta", property="og:description"))

requests는 HTTP 요청을 보내고 서버의 초기 응답(raw HTML)만 받아온다. requestsJavaScript를 실행하는 엔진이 없기 때문에, DOM에 동적으로 주입되는 콘텐츠에는 접근할 수 없다. 결과적으로 파싱할 내용이 없는 빈 페이지만 돌아온다.

여기에 더해 인스타그램은 로그인하지 않은 외부 요청에 대해 콘텐츠 노출 자체를 제한하고 있어, 비로그인 단순 HTTP 요청으로는 사실상 아무것도 가져올 수 없다.

✅ 해결책: Playwright로 JS 실행 & BeautifulSoup으로 파싱

이 문제를 해결하려면 실제 브라우저처럼 JavaScript를 실행할 수 있는 도구가 필요하다. 선택지는 크게 두 가지였다.

도구특징
Selenium오랫동안 쓰인 표준, WebDriver 별도 설치 필요
PlaywrightMS 개발, 설치 간단, async 지원, 속도 빠름

우리 프로젝트에서는 Playwright를 선택했다. playwright install chromium 한 줄로 브라우저까지 설치되고, 로그인 세션을 파일로 저장해 재사용하는 기능(storage_state)이 내장되어 있어 반복 실행 시 매번 로그인하는 번거로움을 줄일 수 있었다.

Playwright가 브라우저를 띄워 JS 렌더링까지 마친 후 완성된 HTML을 넘겨주면, 그때부터는 BeautifulSoup이 파싱을 담당한다. 두 라이브러리의 역할 분담은 명확하다.

  • Playwright: JS 실행 + 로그인 + 스크롤 + 페이지 로드
  • BeautifulSoup: 완성된 HTML에서 데이터 추출

전체 흐름을 코드로 요약하면 다음과 같다.

# adapters/inbound/instagram/scraper_bs4.py

from playwright.sync_api import sync_playwright
from bs4 import BeautifulSoup

with sync_playwright() as p:
   browser = p.chromium.launch(headless=True)
   page = browser.new_page()
   
   page.goto("https://www.instagram.com/p/ABC123/")
   page.wait_for_timeout(1500) # JS 렌더링 대기
   
   html = page.content() # 렌더링 완료된 HTML
   browser.close()

soup = BeautifulSoup(html, "html.parser")
# 이제 실제 콘텐츠에 접근 가능

🌟 전체 구조 한눈에 보기

✨ 데이터 흐름

코드가 어떻게 동작하는지 이해하려면 전체 흐름을 먼저 파악하는 게 좋다.각 단계를 간단히 짚으면

  • ① ~ ②: Playwright가 실제 브라우저처럼 프로필 페이지를 열고, 스크롤을 내리며 게시글 URL을 수집한다.
  • ③: 각 게시글 URL에 접속해 HTML을 가져오고, BeautifulSoup으로 캡션·좋아요 수·해시태그·댓글을 파싱RawPost 객체로 만든다.
  • ④: 해시태그와 본문 텍스트를 기반으로 광고 게시글 여부를 판별한다. LLM을 호출하기 전 규칙 기반으로 먼저 걸러내 API 비용을 줄인다.
  • ⑤: 광고로 분류된 게시글만 LLM에 넘겨 유형을 요약하고, 댓글 감정을 분석하고, 해당 인플루언서의 광고 성향을 종합 분석한다.

✨ 핵심 데이터 모델: RawPost

전체 파이프라인의 중심에는 RawPost 데이터 클래스가 있다. 스크래핑한 게시글의 원본 데이터를 담는 그릇으로, 광고 분류와 LLM 분석 모두 이 객체를 입력으로 받는다.

# adapters/inbound/instagram/scraper_bs4.py

@dataclass
class RawPost:
   shortcode: str       # 게시글 고유 코드 (URL의 /p/{shortcode}/)
   post_url: str        # 게시글 전체 URL
   caption: str         # 게시글 본문
   hashtags: List[str]  # 해시태그 리스트 (예: ["#광고", "#뷰티"])
   posted_at: datetiem  # 게시글 작성 시각
   likes: int           # 좋아요 수
   username: str        # 계정명
   comments: List[str]  # 상위 댓글 텍스트 목록

클래스명에 Raw가 붙은 이유는, AI 분류가 이루어지기 전 단계의 순수한 수집 데이터임을 명시하기 위해서이다. 광고 여부가 판별되고 나면 AdPost 엔티티로 변환된다.

@dataclass란?

Python의 @dataclass데코레이터로, 데이터를 담는 클래스를 간편하게 만들어주는 기능이다.

  • @dataclass를 사용하지 않는 경우

    class RawPost:
       def __init__(self, shortcode, post_url, caption, likes):
          self.shortcode = shortcode
          self.post_url = post_url
          self.caption = caption
          self.likes = likes
  • @dataclass를 사용하는 경우

    @dataclass
    class RawPost:
       shortcode: str
       post_url: str
       caption: str
       likes: int

    결과는 동일한데 코드가 훨씬 짧아진다. __init__을 직접 작성하지 않아도 자동으로 생성해준다.

  • @dataclass가 추가로 자동 생성해주는 것들

    기능설명
    __init__생성자 자동 생성
    __repr__print(post) 했을 때 필드값 보기 좋게 출력
    __eq__두 객체의 필드값이 같으면 동일하다고 판단

🌟 Playwright로 브라우저 자동화

Playwright의 역할은 크게 세 가지이다.

  1. 로그인 상태 유지
  2. 프로필 페이지에서 게시글 URL 수집
  3. 각 게시글 페이지 로드

이 세 가지가 완료되어야 BeautifulSoup이 파싱할 HTML을 넘겨받을 수 있다.

✨ 브라우저 컨텍스트 설정

Playwright는 브라우저 자체(browser)브라우저 컨텍스트(context)를 분리한다. 컨텍스트쿠키, 세션, User-Agent 같은 환경 설정을 담당하는 단위이다.

context = browser.new_context(
   storage_state=storage_state,  # 저장된 세션 파일 경로
   user_agent=(
      "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
      "AppleWebKit/537.36 (KHTML, like Gecko) "
      "Chrome/122.0.0.0 Safari/537.36"
   ),
   viewport={"width": 1280, "height": 800},
   locale="ko-KR",
)

user_agent를 실제 Chrome 브라우저 값으로 지정한 이유는, 기본값으로 두면 Playwright 봇임이 드러나 인스타그램이 요청을 차단할 가능성이 높기 때문이다. 따라서 실제 Windows Chrome 브라우저의 User-Agent 값을 그대로 넣어 일반 사용자처럼 위장한다.
localeko-KR로 설정한 것도 같은 맥락으로, 일반 한국 사용자처럼 보이게 하기 위해서이다.

✨ 세션 저장과 재사용

매번 실행할 때마다 로그인을 반복하면 번거롭기도 하고, 잦은 로그인 요청이 인스타그램의 보안 감지를 유발할 수 있다.
Playwright는 storage_state라는 기능으로 현재 브라우저의 쿠키와 로컬 스토리지를 파일로 저장할 수 있다.

# adapters/inbound/instagram/scraper_bs4.py

def _save_session(self, context) -> None:
   session_file = self._session_file()
   SESSION_DIR.mkdir(exist_ok=True)
   context.storage_state(path=str(session_file))

저장된 세션 파일이 있으면 다음 실행 시 로그인 과정 없이 바로 인증된 상태로 시작한다. 세션이 만료되면 그때만 다시 로그인하면 된다.

여기에 더해 Chrome 브라우저에서 직접 추출한 쿠키 파일(chrome_cookies.json)을 주입하는 방법도 지원한다. 실제 사용자의 브라우저 쿠키를 그대로 사용하기 때문에 봇 감지를 피하는 데 가장 효과적인 방법이다.

✨ 로그인 처리

# adapters/inbound/instagram/scraper_bs4.py

def _ensure_login(self, page) -> None:
   page.goto("https://www.instagram.com/accounts/login/")
   page.wait_for_timeout(2000)
   
   if "accounts/login" not in page.url:
      # 로그인 페이지가 아닌 곳으로 이동했다면 이미 로그인 상태
      return
   
   page.fill('input[name="username"]', self._login_username)
   page.fill('input[name="password"]', self._login_password)
   page.click('button[type="submit"]')
   page.wait_for_timeout(5000)  # 로그인 후 팝업 대기

로그인 페이지로 이동했을 때 URL을 확인해서 이미 세션이 유효한지를 먼저 체크한다. 세션이 살아있으면 인스타그램이 홈으로 리다이렉트하기 때문에, accounts/login이 URL에 없으면 로그인 과정을 건너뛴다.

로그인 후 wait_for_timeout(5000)으로 5초를 기다리는 이유는, 인스타그램이 로그인 직후 "로그인 정보를 저장하시겠어요?" 같은 팝업을 띄우는 경우가 있어서이다. 이 팝업이 완전히 처리되기 전에 다음 동작으로 넘어가면 오류가 생길 수 있다.

✨ 프로필 페이지에서 게시글 URL 수집

인스타그램 프로필 페이지는 처음에 게시글을 약 12개만 보여주고, 스크롤을 내려야 추가로 로드된다. 원하는 수만큼 URL을 모으려면 스크롤을 반복하면서 링크를 계속 수집해야 한다.

while len(urls) < max_posts and scroll_attempts < max_scrolls:
   links = page.locator('a[href*="/p/"], a[href*="/reel/"]').all()
   for link in links:
      href = link.get_attribute("href")
      if href and href not in seen:
         seen.add(href)
         urls.append(f"https://www.instagram.com{href}")
         
   page.keyboard.press("End")  # 페이지 끝으로 스크롤
   page.wait_for_timeout(int(self.request_delay * 1000))
   scroll_attempts += 1

a[href*="/p/"]는 일반 게시글, a[href*="/reel/"]는 릴스 링크를 선택하는 CSS 셀렉터이다.
seen 집합으로 중복 URL을 걸러내고, End 키를 눌러 스크롤을 내린 뒤 추가 게시글이 로드될 때까지 대기한다.

✨ 게시글 페이지 로드와 에러 감지

# adapters/inbound/instagram/scraper_bs4.py

def _fetch_page(self, page, url: str) -> str:
   page.goto(url, wait_until="domcontentloaded", timeout=30_000)
   page.wait_for_timeout(1500)  # JS 렌더링 대기
   html = page.content()
   
   if "challenge" in page.url:
      raise RuntimeError("Instagram 챌린지 페이지 감지 (계정 확인 필요)")
   if "Too Many Requests" in html or "429" in page.title():
      raise RuntimeError("Instagram 요청 제한(429) 감지")
   if "로그인" in page.title() or "Login" in page.title():
      raise RuntimeError("Instagram 로그인 페이지로 리다이렉트 - 세션 만료")
      
   return html

domcontentloaded 이벤트 이후에도 wait_for_timeout(1500)으로 1.5초를 추가로 기다린다. HTML 뼈대가 로드됐더라도 JS가 데이터를 DOM에 삽입하는 데 시간이 걸리기 때문이다.
이후 세 가지 비정상 상태를 감지한다.

상태의미
URL에 challenge 포함인스타그램이 계정 본인 확인을 요구하는 상태
429 응답요청이 너무 많아 일시적으로 차단된 상태
제목이 로그인 페이지세션이 만료되어 로그인 페이지로 튕긴 상태

이 중 하나라도 감지되면 예외를 던져 해당 게시글을 건너뛰고 다음으로 넘어간다.


🌟 BeautifulSoup으로 게시글 파싱

Playwright가 게시글 페이지를 로드하고 나면, 완성된 HTMl을 BeautifulSoup에 넘겨 데이터를 추출한다. 여기서 핵심은 단일 파싱 방법에 의존하지 않는다는 것이다.

인스타그램은 내부 구조를 자주 바꾼다. 어떤 게시글에는 특정 데이터가 있고, 어떤 게시글에는 없는 경우도 있다. 하나의 방법만 쓰면 구조가 바뀌는 순간 아무것도 가져오지 못하기 때문에, 3단계 fallback 전략을 설계했다.

# adapters/inbound/instagram/scraper_bs4.py

def _parse_post(self, soup, url: str, username: str) -> Optional[RawPost]:
   """BeautifulSoup으로 게시글 페이지 파싱
   
   우선순위:
     1. <script type="application/ld+json"> - 가장 안정적
     2. 페이지 내 인라인 JSON - fallback
     3. <meta> 태그 - 최후 수단
     
   post = self._parse_from_json_ld(soup, url, username)  # 1순위
   if post:
      return post
      
   post = self._parse_from_inline_json(soup, url, username)  # 2순위
   if post:
      return post
      
   return self._parse_from_meta(soup, url, username)  # 3순위

1순위부터 시도하고, 실패하면 다음 방법으로 넘어간다. 각 방법이 어떻게 동작하는지 하나씩 살펴보자.

🧩 1순위: <script type="application/ld+json"> 파싱

가장 먼저 시도하는 방법이다. ld+json검색엔진을 위해 페이지의 구조화된 데이터를 JSON 형태로 담아두는 표준 포맷으로, 인스타그램도 이 태그를 통해 게시글 정보를 제공한다.

def _parse_from_json_ld(self, soup, url: str, username: str) -> Optional[RawPost]:
   """<script type="application/ld+json"> 태그에서 파싱"""
   for script in soup.find_all("script", type="application/ld+json"):
      data = json.loads(script.string or "")
   
      caption = data.get("caption") or data.get("description") or ""
      hashtags = re.findall(r"#\w+", caption)
      date_str = data.get("uploadDate") or data.get("datePublished") or ""
   
      interaction = data.get("interactionStatistic")
      if isinstance(interaction, list):
         likes = interaction[0].get("userInteractionCount", 0)
      elif isinstance(interaction, dict):
         likes = interaction.get("userInteractionCount", 0)

caption을 먼저 시도하고 없으면 description을 쓰는 이유는, 인스타그램이 필드명을 일관되게 유지하지 않기 때문이다. 날짜도 마찬가지로 uploadDatedatePublished 두 가지를 모두 확인한다.
좋아요 수가 담긴 interactionStatistic은 리스트일 때도 있고 딕셔너리일 때도 있어서 두 경우를 모두 처리한다.

이 방법이 1순위인 이유는 구조화된 표준 포맷이라 파싱이 가장 안정적이고, 인스타그램이 DOM 구조를 바꿔도 이 태그는 비교적 오래 유지되는 경향이 있기 때문이다.

🧩 2순위: 인라인 JSON 파싱

ld+json에서 데이터를 못 가져왔을 때 시도하는 방법이다. 인스타그램은 페이지 내 <script> 태그 안에 게시글 데이터를 JSON 형태로 직접 박아두는 경우가 있다.

def _parse_from_inline_json(self, soup, url: str, username: str) -> Optional[RawPost]:
   """페이지 내 <script> 인라인 JSON에서 파싱"""
   for script in soup.find_all("script"):
      text = script.string or ""
      if "shortcode_media" not in text and "edge_media_to_caption" not in text:
         continue
      
      cap_match = re.search(r'"text":"(.*?)"', text)
      caption = cap_match.group(1).replace("\\n", "\n") if cap_match else ""
   
      ts_match = re.search(r'"taken_at_timestamp":(\d+)', text)
      posted_at = datetime.fromtimestamp(int(ts_match_group(1)), tz=timezone.utc)
   
      likes_match = re.search(r'"edge_media_preview_like":\{"count":(\d+)}', text)
      likes = int(likes_match.group(1)) if likes_match else 0

모든 <script> 태그를 순회하면서 shortcode_media 또는 edge_media_to_caption 키워드가 있는 것만 골라낸다. 이 키워드가 있는 스크립트가 게시글 데이터를 담고 있는 블록이다.

데이터를 통째로 json.loads()로 파싱하지 않고 정규식으로 필요한 필드만 뽑아내는 이유는, 인라인 JSON이 수백 KB에 달하는 거대한 덩어리인 경우가 많아 전체를 파싱하면 느리고 불안정하기 때문이다.

🧩 3순위: <meta> 태그 파싱

앞의 두 방법이 모두 실패했을 때 마지막으로 시도하는 방법이다. 인스타그램은 SNS 공유를 위해 og:description 메타 태그를 제공하는데, 여기에 게시글 요약 정보가 담겨 있다.

def _parse_from_meta(self, soup, url: str, username: str) -> Optional[RawPost]:
   """<meta> 태그에서 파싱. og:description 형식에서 likes/date 추출."""
   desc_tag = soup.find("meta", property="og:description")
   caption = desc_tag["content"] if desc_tag else ""

og:description의 실제 값은 이런 형식이다.

93K likes, 467 comments - username - March 19, 2026: "캡션 내용..."

좋아요 수와 날짜가 텍스트 안에 섞여 있어서, 정규식으로 파싱해야 한다.

# 좋아요 수 추출: "93K likes" → 93000, "9,424 likes" → 9424
def _parse_likes_from_description(description: str) -> int:
   match = re.match(r'^([\d,.]+)(K?)\s+likes', description.strip())
   num = float(match.group(1).replace(",", ""))
   return int(num * 1000) if match.group(2) == "K" else int(num)
   
# 날짜 추출: "March 19, 2026" → datetime(2026, 3, 19)
def _parse_date_from_description(description: str) -> datetime:
   match = re.search(r"-\s+([A-Z][a-z]+ \d{1,2}, \d{4})\s*:", description)
   return datetime.strptime(match.group(1), "%B %d, %Y")

이 방법이 3순위인 이유는 정보가 가장 적기 때문이다. 캡션이 잘릴 수 있고, 좋아요 수를 93K처럼 근사값으로만 제공하며, 댓글은 아예 없다.

🤔 3단계 fallback이 필요한 이유

세 방법을 정리하면 다음과 같다.

순위방법장점단점
1순위ld+json가장 안정적, 표준 포맷없는 게시글도 있음
2순위인라인 JSON데이터 풍부구조가 자주 바뀜
3순위meta 태그거의 항상 존재정보 적고 부정확

인스타그램이 언제 어떤 구조로 응답할지 예측할 수 없다. 1순위 방법 하나만 믿었다가 인스타그램이 해당 태그를 없애버리면 전체 수집이 멈춘다.
3단계로 나눠두면 어떤 상황에서도 최소한의 데이터는 가져올 수 있다.


🌟 댓글 수집 경로

게시글 본문(캡션, 좋아요 수 등)과 마찬가지로, 댓글도 단일 방법에만 의존하지 않는다. 인스타그램 페이지 구조에 따라 댓글 데이터가 있는 위치가 달라지기 때문에 두 가지 경로를 순서대로 시도한다.

# adapters/inbound/instagram/scraper_bs4.py

def _fetch_comments(self, page, html: str, max_comments: int = 20) -> List[str]:
   """게시글 페이지에서 댓글 수집
   
   1순위: 인라인 JSON (edge_media_to_comment)
   2순위: Playwright 로케이터로 렌더링된 DOM 파싱
   """

🧩 1순위: 인라인 JSON에서 추출

게시글 파싱 2순위 방법과 마찬가지로, 인스타그램은 페이지 HTML 안에 댓글 데이터를 JSON 형태로 박아두는 경우가 있다. edge_media_to_comment라는 키 아래에 댓글 목록이 담겨 있다.

def _comments_from_inline_json(self, html: str, max_comments: int) -> List[str]:
   """페이지 인라인 JSON의 edge_media_to_comment에서 댓글 텍스트 추출"""
   comments = []
   pattern = re.compile(r'"edge_media_to_comment".*?"edges":\[(.*?)\]', re.DOTALL)
   match = pattern.search(html)
   if not match:
      return comments
   try:
      # 개별 node에서 "text" 필드 추출
      for text_match in re.finditer(r'"text"\s*:\s*"((?:[^"\\]|\\.)*)"', match.group(1)):
         text = text_match.group(1).replace("\\n", "\n").replace('\\"', '"')
         if text:
            comments.append(text)
            if len(comments) >= max_comments:
               break
   except Exception as e:
      logger.debug(f"인라인 JSON 댓글 파싱 실패: {e}")
   return comments

정규식이 두 단계로 나뉜다.

  1. edge_media_to_comment 블록 전체를 찾아 edges 배열 내용만 추출한다.
    re.DOTALL 옵션을 준 이유는 댓글이 여러 줄에 걸쳐 있어 .이 줄바꿈도 매칭해야 하기 때문이다.
  2. 추출한 블록 안에서 "text" 필드값만 골라낸다.
    ((?:[^"\\]|\\.)*) 패턴은 이스케이프된 문자(\", \n 등)를 포함한 문자열을 정확하게 캡쳐하기 위한 표현이다. 추출 후 \\n은 실제 줄바꿈으로, \"는 따옴표로 복원한다.

JSON 전체를 파싱하지 않고 정규식으로 필요한 부분만 뽑는 이유는 게시글 파싱 때와 동일하다. 인라인 JSON이 수백 KB에 달하는 경우가 많아 전체를 파싱하면 느리고 불안정하다.

🧩 2순위: DOM에서 직접 추출

인라인 JSON에서 댓글을 찾지 못했을 때 시도하는 방법이다. Playwright가 JS 렌더링을 마친 DOM에서 직접 댓글 텍스트를 읽어온다.

spans = page.locator('span[dir="auto"]').all()
seen = set()
is_first = True # 첫 번째 span은 캡션이므로 스킵
for span in spans:
   try:
      text = span.inner_text().strip()
      if not text or len(text) < 10:
         continue
      if text.startswith("©"): # 푸터 스킵
         continue
      if is_first: # 첫 번째 긴 텍스트 = 캡션 스킵
         is_first = False
         continue
      if text not in seen:
         seen.add(text)
         comments.append(text)
         if len(comments) >= max_comments:
            break
   except Exception:
      continue

span[dir="auto"]를 선택하는 이유는, 인스타그램이 사용자가 입력한 텍스트(캡션, 댓글)에 dir="auto" 속성을 붙이기 때문이다. 언어에 따라 텍스트 방향을 자동으로 결정하는 HTML 속성으로, 사용자 입력 텍스트를 구분하는 기준점으로 활용할 수 있다.

그런데 이 셀렉터로 잡히는 span에는 댓글뿐 아니라 캡션도 포함된다. 캡션을 댓글로 오인하지 않도록, 첫 번째로 등장하는 긴 텍스트(is_first)는 무조건 캡션으로 간주하고 건너뛴다.

🤔 첫 번째로 등장하는 긴 텍스트가 캡션이라고 확신할 수 있을까?

사실 확신할 수 없다. 경험적으로 그렇더라는 가정에 기반한 휴리스틱이다.
인스타그램 DOM 구조상 캡션이 댓글보다 위에 렌더링되기 때문에, span[dir="auto"]를 순서대로 순회하면 캡션이 가장 먼저 나오는 경향이 있다. 그래서 첫 번째 긴 텍스트를 캡션으로 간주하고 건너뛴다.
하지만 이건 몇 가지 상황에서 틀릴 수 있다.

  • 캡션이 없는 게시글이면 첫 번째 댓글을 캡션으로 오인해 버림
  • 인스타그램이 DOM 렌더링 순서를 바꾸면 무너짐
  • 캡션이 10자 미만으로 짧으면 len(text) < 10 조건에 걸려 스킵되고, 그 다음 텍스트가 캡션으로 오인될 수 있음

그래서 이 방법이 2순위인 이유이기도 하다. 1순위인 인라인 JSON은 캡션과 댓글이 구조적으로 분리된 별도 필드에 담겨 있어 이런 오인 문제가 없다.
DOM 파싱은 어디까지나 마지막 수단으로, 완벽하지 않다는 걸 감수하고 쓰는 방법이다.

추가로 세 가지 노이즈를 걸러낸다.

조건이유
len(text) < 10이모지 하나, 짧은 반응 등 의미 없는 텍스트 제거
text.startswith("©")페이지 하단 저작권 표시 푸터 제거
text in seen같은 텍스트가 DOM에 중복으로 존재하는 경우 제거

🤔 두 경로가 필요한 이유

1순위 (인라인 JSON)2순위 (DOM)
속도빠름 (HTML 텍스트 검색)느림 (렌더링된 DOM 순회)
안정성인스타그램 내부 구조에 의존비교적 안정적
데이터 품질원본 텍스트 그대로노이즈 필터링 필요

인라인 JSON은 빠르고 정확하지만 항상 존재하지는 않는다.
DOM 파싱은 느리고 노이즈 처리가 필요하지만 렌더링만 됐다면 거의 항상 동작한다.
두 경로를 조합해 어떤 상황에서도 댓글을 수집할 수 있도록 설계했다.


🌟 규칙 기반 광고 분류

게시글 수집이 끝나면 다음 단계는 광고 게시글을 골라내는 것이다. 수집한 게시글 전부를 LLM에 넘겨 분류할 수도 있지만, 그렇게 하면 API 비용이 게시글 수에 비례해 늘어난다.
그래서 LLM을 호출하기 전에 규칙 기반으로 먼저 걸러내는 단계를 뒀다.
규칙 기반 분류는 두 가지를 본다. 해시태그캡션 본문이다.

# scripts/_feed_common.py

def is_ad(post: RawPost) -> bool:
   hashtags_lower = {tag.lower() for tag in post.hashtags}
   if hashtags_lower & EXPLICIT_AD_TAGS:
      return True
   if _AD_PATTERN.search(post.caption):
      return True
   return False

✨ 명시적 광고 해시태그 필터

한국 인플루언서들이 광고 게시글에 공통적으로 사용하는 해시태그들이 있다. 국내 광고 표준 가이드라인에 따라 광고임을 명시해야 하는 의무가 있어, 대부분의 광고 게시글에는 이런 태그가 붙는다.

EXPLICIT_AD_TAGS = {
   "#광고", "#협찬", "#ad", "#sponsored", "#유료광고", "#협찬받음", "#제품협찬"
}

해시태그 비교는 대소문자를 구분하지 않도록 소문자로 통일한 뒤 집합 교집합 연산으로 판별한다.

hashtags_lower = {tag.lower() for tag in post.hashtags}
if hashtags_lower & EXPLICIT_AD_TAGS:
   return True

& 연산자는 두 집합의 교집합을 구한다. 게시글 해시태그 중 하나라도 EXPLICIT_AD_TAGS에 속하면 즉시 광고로 판단하고 반환한다. 리스트를 순회하며 하나씩 비교하는 것보다 집합 연산이 훨씬 빠르다.
이 단계에서 광고로 판별되면 LLM을 호출하지 않는다. #광고#협찬 같은 태그가 붙어 있으면 광고임이 명백하기 때문에, LLM에 물어볼 필요가 없다.

✨ 캡션 본문 정규직 패턴 매칭

해시태그로 잡히지 않는 경우도 있다. 태그 없이 본문에 광고 관련 표현을 쓰는 게시글이 그 경우이다. 이를 위해 캡션 텍스트를 정규식으로 검사한다.

_AD_PATTERN = re.compile(
   r"제공\s*받았습니다|제공받음|협업|파트너십|할인\s*코드|구매\s*링크|링크\s*인\s*바이오"
   r"|sponsored|유료\s*광고|협찬|제품\s*제공|무상\s*제공",
   re.IGNORECASE,
)
if _AD_PATTERN.search(post.caption):
   return True

패턴에 포함된 표현들을 살펴보면 의도가 보인다.

표현의미
제공\s*받았습니다, 제공받음브랜드로부터 제품을 받았다는 명시
협업, 파트너십브랜드와의 협업 관계 표현
할인\s*코드, 구매\s*링크구매 유도 장치 포함
링크\s*인\s*바이오프로필 링크로 유도하는 광고 전형 표현
무상\s*제공무료로 제품을 받았다는 표현

\s*를 중간에 넣은 이유는 "제공 받았습니다", "제공받았습니다"처럼 띄어쓰기가 있는 경우와 없는 경우를 모두 잡기 위해서이다.
re.IGNORECASE는 영어 표현(sponsored)의 대소문자 변형을 모두 커버한다.

✨ 전체 흐름

두 단계를 합치면 is_ad() 함수의 판단 흐름은 다음과 같다.

⚠️ 규칙 기반의 한계

이 방법이 모든 광고를 잡아내지는 못한다. 태그도 없고 본문에도 명시적 표현이 없는 암묵적 광고는 걸러내지 못한다. 브랜드 제품을 자연스럽게 노출하면서 광고 표시를 의도적으로 생략하는 경우도 있기 때문이다.
그래서 이 규칙 기반 분류로 수집한 광고 데이터는 "명시적으로 광고임을 밝힌 게시글"에 한정된다는 점을 염두에 둬야 한다. 암묵적 광고까지 잡아내려면 LLM을 판별 단계에도 투입하는 방식을 고려해볼 수 있지만, 그만큼 API 비용과 처리 시간이 늘어난다는 트레이드오프가 있다.


🌟 LLM으로 광고 내용 분석

규칙 기반으로 광고 게시글을 골라냈다면, 이제 각 게시글을 LLM에 넘겨 내용을 분석한다. 분석은 세 단계로 이루어진다. 광고 유형 요약, 댓글 감정 분석, 인플루언서 광고 성향 전체 분석이다.

✨ 멀티 프로바이더 지원

LLM 호출 전에 먼저 어떤 LLM을 쓸지 결정한다. 이 프로젝트는 Ollama, OpenAI, Anthropic 세 가지 프로바이더를 지원하며, .env 설정에 따라 자동으로 선택된다.

# scripts/_feed_common.py

def build_llm():
   provider = settings.llm_provider
   
   if provider == "ollama":
      return ChatOllama(model=settings.llm_model, temperature=0.3)
   elif provider == "openai":
      return ChatOpenAI(api_key=settings.openai_api_key, temperature=0.3)
   elif provider == "anthropic":
      return ChatAnthropic(api_key=settings.anthropic_api_key, temperature=0.3)

temperature=0.3으로 설정한 이유는, 너무 낮으면 획일적인 답변이 나오고 너무 높으면 일관성이 떨어지기 때문이다.
분석 요약처럼 창의성보다 정확성이 중요한 태스크에서는 0.3 정도가 적절한 균형점이다.

🧩 1단계: 광고 유형 요약

각 광고 게시글의 캡션을 보고 어떤 종류의 광고인지 한 문장으로 요약한다.

# scripts/scrape_ad_feed.py

_AD_VIBE_PROMPT = """다음은 Instagram 광고 게시글 캡션입니다.
이 광고가 어떤 종류의 광고인지 한 문장(20자 이내)으로 요약해주세요.
예: "뷰티 제품 협찬 광고", "여행지 홍보 콘텐츠", "패션 브랜드 협업", "건강식품 할인 프로모션"

캡션:
<caption>
{caption}
</caption>

반드시 한국어로만 한 문장으로 답하세요."""

프롬프트 설계에서 신경 쓴 부분이 두 가지 있다.

첫째, 예시를 제공했다. "뷰티 제품 협찬 광고"처럼 원하는 출력 형태를 직접 보여주면 LLM이 형식을 맞추기 훨씬 쉽다. 예시 없이 "요약해줘"만 쓰면 LLM마다 출력 형식이 제각각이 된다.

둘째, 캡션을 300자로 제한했다.

resp = llm.invoke([HumanMessage(content=_AD_VIBE_PROMPT.format(caption=caption[:300]))])

인스타그램 캡션은 길게는 수천 자에 달하기도 한다. 광고 유형 파악에는 앞부분만으로 충분하고, 전체를 넘기면 토큰 낭비이다.

🧩 2단계: 댓글 감정 분석

수집한 댓글을 보고 팔로워들의 반응을 요약하고 감정을 분류한다.

# scripts/scrape_ad_feed.py

_AD_REACTION_PROMPT = """다음은 Instagram 광고 게시글에 달린 댓글들입니다.

댓글:
<comments>
{comments}
</comments>

이 댓글들을 읽고 사람들의 전반적인 반응 감정을 분석해주세요.
1. 반응 요약: 한 문장(30자 이내)으로 요약
2. 감정 분류: "긍정", "부정", "중립" 중 하나만 선택

반드시 아래 형식으로만 한국어로 답하세요:
반응: <요약 문장>
감정: <긍정|부정|중립>"""

출력 형식을 반응:, 감정:으로 고정한 이유는 파싱하기 위해서이다. LLM 응답을 그대로 쓰는 게 아니라, 줄별로 파싱해서 두 값을 분리해 저장한다.

for line in text.splitlines():
   if line.startswith("반응:"):
      reaction = line.removeprefix("반응:").strip()
   elif line.startswith("감정:"):
      raw_sentiment = line.removeprefix("감정:").strip()
      if raw_sentiment in ("긍정", "부정", "중립"):
         sentiment = raw_sentiment

감정 분류 값을 ("긍정", "부정", "중립") 중 하나인지 명시적으로 검증하는 이유는, LLM이 가끔 "긍정적"이나 "대체로 긍정" 같은 변형된 표현을 출력하기 때문이다. 허용된 값이 아니면 기본값인 "중립"으로 처리한다.
댓글은 최대 15개까지만 넘긴다.

comments_text = "\n".join(f"- {c[:100]}" for c in comments[:15])

댓글이 많다고 분석이 정확해지지 않고, 토큰만 늘어난다. 각 댓글도 100자로 잘라서 넘긴다.

🧩 3단계: 인플루언서 광고 성향 전체 분석

게시글 단위 분석이 끝나면 마지막으로 인플루언서 전체를 종합 분석한다. 개별 광고 유형 요약들을 모아서 넘기고, 이 인플루언서의 광고 활동 전반을 평가하게 한다.

# scripts/scrape_ad_feed.py

_AD_OVERALL_PROMPT = """다음은 인스타그램 인플루언서 @{username}의 광고 피드글 {count}개의 분석 결과입니다.

{ad_list}

이 인플루언서의 광고 활동 성향을 3~5문장으로 분석해주세요.
- 주로 어떤 카테고리의 광고를 진행하는지
- 광고 콘텐츠의 톤과 스타일
- 팔로워들의 광고에 대한 반응 경향

주의: 성별을 알 수 없으므로 '그', '그녀' 같은 성별 대명사 대신 '이 인플루언서'로만 지칭하세요."""

프롬프트 마지막에 성별 대병사 사용 금지를 명시한 이유는, LLM이 계정명이나 콘텐츠 분위기를 보고 성별을 임의로 추정해 "그녀는~" 같은 표현을 쓰는 경우가 있기 때문이다. 실제로 써보니 이 한 줄이 없으면 LLM이 종종 성별을 단정 짓는 표현을 사용했다.

✨ 세 단계 분석 결과 저장

세 단계가 끝나면 게시글별 분석 결과와 전체 분석을 하나의 JSON으로 저장한다.

result = {
   "username": username,
   "scraped_at": datetime.now(tz=timezone.utc).isoformat(),
   "stats": {
      "total_fetched": len(raw_posts),
      "non_ad_count": len(non_ad_posts),
      "ad_count": len(ad_posts),
   },
   "overall_analysis": overall,  # 전체 성향 분석
   "posts": [
      {
         "ad_vibe": vibe,        # 광고 유형 요약
         "reaction": reaction,   # 댓글 반응 요약
         "sentiment": sentiment, # 긍정/부정/중립
         ...
      }
   ]
}

이 JSON이 이후 Neo4j에 저장되어 인플루언서 매칭 시 참고 데이터로 활용된다.


🌟 실전에서 겪은 트러블슈팅

🚨 429 에러: 요청이 너무 많다

instaloader를 쓸 때 가장 먼저 부딪힌 문제이다. 처음엔 잘 동작하는 것처럼 보이다가, 어느 순간부터 모든 요청에 429 에러가 떴다.

ConnectionException: 429 Too Many Requests

인스타그램은 짧은 시간 안에 요청이 집중되면 해당 IP나 계정을 일시적으로 차단한다. Playwright로 바꾼 뒤에도 이 문제는 남아 있었다. 게시글을 너무 빠르게 연속으로 요청하면 마찬가지로 막혔다.

해결책은 요청 사이에 딜레이를 두는 것이다.

if i < len(post_urls) - 1:
   time.sleep(self.request_delay)  # 기본값 2.0초

게시글 하나를 가져올 때마다 2초를 기다린다. 스크롤로 추가 게시글을 로드할 때도 마찬가지로 딜레이를 준다.

page.keyboard.press("End")
page.wait_for_timeout(int(self.request_delay * 1000))

429가 뜨면 일정 시간 기다렸다가 재시도하는 게 유일한 방법이다.

🚨 챌린지 페이지: 인스타그램이 본인 확인을 요구한다

스크래핑 중 갑자기 URL이 이렇게 바뀌는 경우가 있다.

https://www.instagram.com/challenge/...

인스타그램이 비정상적인 접근으로 감지하고 계정 본인 확인을 요구하는 페이지이다. 이 상태에서는 어떤 요청을 보내도 정상적인 응답이 오지 않는다.

if "challenge" in page.url:
   raise RuntimeError("Instagram 챌린지 페이지 감지 (계정 확인 필요)")

코드에서는 이 상태를 감지하면 예외를 던져 수집을 중단한다. 자동으로 해결할 방법이 없기 때문에, 실제 브라우저로 인스타그램에 접속해서 본인 확인을 직접 완료한 뒤 다시 실행해야 한다.

이 문제를 줄이는 데 가장 효과적이었던 방법은 Chrome 쿠키를 직접 주입하는 것이었다. 실제 내가 사용하는 Chrome 브라우저의 쿠키를 추출해서 Playwright 컨텍스트에 주입하면, 인스타그램 입장에서는 평소와 동일한 브라우저 환경으로 인식한다.

chrome_cookies_path = SESSION_DIR / "chrome_cookies.json"
if chrome_cookies_path.exists():
   context.add_cookies(cookies)

🚨 세션 만료: 갑자기 로그인 페이지로 튕긴다

저장해둔 세션 파일이 만료되면 게시글 페이지에 접속했을 때 로그인 페이지로 리다이렉트된다.

if "로그인" in page.title() or "Login" in page.title():
   raise RuntimeError("Instagram 로그인 페이지로 리다이렉트 - 세션 만료")

페이지 제목으로 감지하는 이유는, 리다이렉트가 일어나도 URL이 항상 accounts/login으로 바뀌지 않는 경우가 있기 때문이다. 제목이 로그인 관련 텍스트면 세션이 만료된 것으로 판단한다.
locale="ko-KR"로 설정했기 때문에 한국어 "로그인"과 영어 "Login" 두 가지를 모두 체크한다.
세션이 만료되면 세션 파일을 삭제하고 다시 로그인한 뒤 새 세션을 저장해야 한다.

🚨 JS 렌더링 타이밍: 페이지는 로드됐는데 데이터가 없다

domcontentloaded 이벤트가 발생해도 실제 게시글 데이터가 DOM에 없는 경우가 있었다. HTML 뼈대는 로드됐지만 JavaScript가 API를 호출해 데이터를 삽입하는 데 시간이 더 걸리기 때문이다.

page.goto(url, wait_until="domcontentloaded", timeout=30_000)
page.wait_for_timeout(1500)  # JS 렌더링 추가 대기

domcontentloaded 이후 1.5초를 추가로 기다리는 것으로 해결했다. 네트워크 상황에 따라 부족한 경우도 있어서, 파싱 결과가 없으면 대기 시간을 늘려보는 게 첫 번째 시도할 방법이다.

🛠️ 디버깅: headless=False로 브라우저 직접 보기

문제가 생겼을 때 가장 유용했던 방법은 브라우저를 눈에 보이게 띄우는 것이다.

browser = p.chromium.launch(headless=False)

headless=False로 설정하면 실제 Chrome 창이 열리면서 Playwright가 무엇을 하는지 눈으로 볼 수 있다. 챌린지 페이지가 뜨는지, 로그인이 제대로 되는지, 스크롤이 동작하는지를 직접 확인할 수 있어서 원인 파악이 훨씬 빠르다.

코드에서 기본값을 headless=False로 설정해둔 이유가 바로 이것이다. 개발하는 동안에는 눈으로 보면서 디버깅하는 게 훨씬 편하기 때문이다. 안정적으로 동작하는 게 확인되면 headless=True로 바꿔 백그라운드에서 실행할 수 있다.


🌟 마치며

인스타그램 스크래핑은 단순히 HTML을 긁어오는 작업이 아니었다. instaholder의 429 차단, SPA 구조의 벽, 수시로 바뀌는 DOM 구조까지 예상보다 훨씬 많은 문제를 마주쳤고, 그 과정에서 설계 결정들이 하나씩 쌓였다.

돌아보면 이 작업에서 가장 중요했던 판단은 두 가지였다.

첫째, 단일 방법에 의존하지 않는 것이다.
파싱 3단계 fallback이든, 댓글 수집 2경로든, 인스타그램이 언제 구조를 바꿀지 모른다는 전제 아래 항상 대안을 준비해뒀다. 덕분에 한 가지 방법이 막혀도 전체 수집이 멈추지 않았다.

둘째, LLM 호출 범위를 명확히 구분한 것이다.
규칙으로 판별할 수 있는 건 규칙으로 처리하고, LLM은 사람이 판단해야 할 영역인 내용 분석에만 투입했다. 비용을 절감하면서도 각 도구의 강점을 제대로 활용한 구조라고 생각한다.

💡 한계와 개선 아이디어

물론 한계도 있다.

첫째, 인스타그램 정책 변경에 취약하다.
DOM 구조나 내부 JSON 필드명이 바뀌면 파싱 코드를 다시 손봐야 한다. 이건 공식 API 없이 스크래핑에 의존하는 구조의 근본적인 한계이다.

둘째, 암묵적 광고를 잡지 못한다.
현재 규칙 기반 분류는 명시적으로 광고임을 밝힌 게시글만 잡아낼 수 있다. 태그나 광고 표현 없이 자연스럽게 제품을 노출하는 게시글은 걸러내지 못한다. 이 부분은 LLM을 판별 단계에도 투입하거나, 더 정교한 패턴을 추가하는 방향으로 개선할 수 있다.

셋째, 수집 속도가 느리다.
차단 방지를 위해 요청마다 2초씩 딜레이를 두다 보니, 게시글 50개를 수집하는 데 상당한 시간이 걸린다. 여러 계정을 동시에 분석해야 한다면 비동기 처리나 병렬 수집을 고려해볼 수 있다.

0개의 댓글