[n8n] Cloud 환경에서 Threads 스크래핑하기 - 삽질 기록

2025. 11. 27. 22:12·IT 기술/n8n

1. 배경

텔레그램 봇을 만들고 있었습니다. 링크를 보내면 AI가 내용을 요약해주는 그런 거요.

근데 Threads 링크가 문제였습니다.

Threads는 JavaScript로 콘텐츠를 렌더링합니다. 단순 HTTP Request로는 빈 페이지만 옵니다.

브라우저 자동화(Puppeteer 같은 거)가 필요하다는 뜻이죠. n8n에서 어떻게든 해봅시다.


2. 시도 1: Puppeteer 노드

n8n에 Puppeteer 커뮤니티 노드가 있다는 걸 알았습니다.

설치하려고 했더니... n8n Cloud는 커뮤니티 노드 설치가 안 된답니다.

Self-hosted면 가능한데, Cloud는 보안 문제로 막혀있습니다. 그럼 Code 노드에서 직접?

const puppeteer = require('puppeteer');

역시 안 됩니다. Cloud 환경에 Puppeteer가 설치되어 있을 리가 없죠.

여기서 방향을 틀었습니다. n8n 내부에서 해결하는 건 포기.


3. 시도 2: 외부 서비스 검토

브라우저 자동화를 외부에서 돌리고, n8n에서 API로 호출하는 방식을 찾아봤습니다.

서비스 장점 단점
Browserless Puppeteer 직접 실행 무료 티어 제한적
ScrapingBee 간단한 API JS 렌더링 품질 불확실
Apify 커스텀 Actor 가능, 무료 크레딧 러닝커브

Apify를 선택했습니다. 월 $5 무료 크레딧이 있고, Puppeteer로 원하는 대로 코드를 짤 수 있습니다.


4. 해결: Apify Actor 만들기

4-1. Actor 생성

Apify Console에서 새 Actor를 만듭니다.

  • Template: JS + Puppeteer + Chrome 선택

4-2. 코드 작성

src/main.js를 아래처럼 작성합니다.

더보기
import { Actor } from 'apify';
import { PuppeteerCrawler } from 'crawlee';

await Actor.init();

const input = await Actor.getInput();

const crawler = new PuppeteerCrawler({
    async requestHandler({ page, request }) {
        await page.goto(request.url, { waitUntil: 'networkidle0' });
        await new Promise(resolve => setTimeout(resolve, 3000));

        const results = await page.evaluate(() => {
            const posts = [];
            let originalAuthor = null;

            document.querySelectorAll('div[data-pressable-container="true"]').forEach((el, index) => {
                const authorLink = el.querySelector('a[href^="/@"]');
                const authorId = authorLink?.getAttribute('href') || null;
                const authorName = authorLink?.innerText || null;

                // 첫 번째 게시물 작성자를 원본 작성자로 저장
                if (index === 0 && authorId) {
                    originalAuthor = authorId;
                }

                const contentSpans = el.querySelectorAll('span[dir="auto"]');
                const content = [];
                contentSpans.forEach(span => {
                    const text = span.innerText?.trim();
                    if (text && text !== authorName && text !== "더 보기" && text.length > 2) {
                        content.push(text);
                    }
                });

                const timeEl = el.querySelector('time');
                const timestamp = timeEl?.getAttribute('datetime') || null;

                // 이미지: 프로필 썸네일 제외
                const images = [];
                el.querySelectorAll('img').forEach(img => {
                    const src = img.getAttribute('src');
                    if (src && !src.includes('s150x150') && !src.includes('s42x42')) {
                        images.push(src);
                    }
                });

                const postLinkEl = el.querySelector('a[href*="/post/"]');
                const postUrl = postLinkEl ? `https://www.threads.net${postLinkEl.getAttribute('href')}` : null;

                posts.push({
                    authorId,
                    authorName,
                    content: content.join('\n'),
                    timestamp,
                    images,
                    postUrl,
                    isOriginalAuthor: authorId === originalAuthor
                });
            });

            // 원본 작성자 게시물 또는 관련 댓글만 필터링
            const originalPostPattern = originalAuthor ? originalAuthor.replace('/@', '') : null;

            return posts.filter(post => {
                if (post.authorId === originalAuthor) return true;
                if (originalPostPattern && post.postUrl?.includes(originalPostPattern)) return true;
                return false;
            });
        });

        await Actor.pushData(results);
    },
});

await crawler.run([input.url]);

await Actor.exit();

4-3. Input Schema 설정

.actor/input_schema.json:

{
    "title": "Threads Scraper",
    "type": "object",
    "schemaVersion": 1,
    "properties": {
        "url": {
            "title": "Threads Post URL",
            "type": "string",
            "description": "스크래핑할 Threads 게시물 URL",
            "editor": "textfield"
        }
    },
    "required": ["url"]
}

4-4. 삽질 포인트

여기서 몇 가지 삽질이 있었습니다.

1) page.waitForTimeout 안 됨

최신 Puppeteer에서 deprecated 되었다고 하더군요.

// ❌ 안 됨
await page.waitForTimeout(3000);

// ✅ 이렇게
await new Promise(resolve => setTimeout(resolve, 3000));

 

2) DOM 선택자

Threads는 React Native 웹 빌드라서 클래스명이 x1lliihq 같은 난독화된 형태입니다. 클래스명으로 잡으면 언제 바뀔지 모릅니다. 그래서 상대적으로 안정적으로 보이는 속성을 선택했습니다.

  • div[data-pressable-container="true"] - 게시물 컨테이너
  • a[href^="/@"] - 작성자 링크
  • time - 타임스탬프
  • span[dir="auto"] - 본문 텍스트

3) 프로필 이미지 필터링

처음엔 profile_pic 문자열로 필터링하려 했는데, CDN URL에 그런 패턴이 없었습니다.

대신 썸네일 사이즈로 구분했습니다: s150x150, s42x42가 포함된 URL은 프로필 이미지.

 

4) 피드 추천 게시물 섞임

Threads가 관련 없는 추천 게시물도 같이 보여줍니다. 원본 작성자 게시물이거나, 원본 게시물에 대한 댓글만 필터링하는 로직을 추가했습니다.


5. n8n 연동

Actor를 직접 Tool로 붙이면 문제가 있습니다. Actor 실행 API는 메타데이터만 반환하고, 실제 데이터는 Dataset에 따로 저장됩니다. API를 2번 호출해야 하는 셈이죠:

  1. Actor 실행 → defaultDatasetId 획득
  2. Dataset 조회 → 실제 데이터 획득

AI Agent에 Tool로 붙이려면 이걸 하나로 묶어야 합니다.

 

사실 Actor Tool과 HTTP Request Tool을 같이 써도 되지만, 이러면 별도의 프롬프팅이 더 필요합니다. 실패할 수도 있고요.

일단 저는 실패했습니다. 그래서,

Sub-workflow로 해결

새 Workflow 생성: Threads Scraper

노드 1: When Executed by Another Workflow

  • Input data mode: Define using fields below.
    Add field 누르고,
    • Name: url
    • Type: String

노드 2: Apify : Run an Actor

  • OAuth로 로그인해주시고,
  • Resource: Actor
  • Operation: Run an Actor
  • Actor Source: Apify Store Actors
  • Actor: From list - Thread Post Parser (sinam7/thread-post-parser)
  • Input JSON: (Expression 모드)
    { "url": "{{ $json.url }}" }
  • Wait for Finish 활성화

노드 3: HTTP Request (Dataset 조회)

  • Method: GET
  • URL: https://api.apify.com/v2/datasets/{{ $json.defaultDatasetId }}/items
  • Authentication: 동일

이제 메인 Workflow의 AI Agent에 Call n8n Workflow Tool로 연결하면 끝입니다.


6. 마무리

최종 구조

Telegram Trigger 
  → URL 파싱 
  → Threads Scraper (Sub-workflow)
    → Actor 실행
    → Dataset 조회
  → AI 요약 
  → Telegram 응답

결과

잘 됩니다. 👍

Threads 게시물 URL을 보내면 본문 + 댓글까지 긁어서 AI가 요약해줍니다.

참고

  • Threads DOM 구조는 언제든 바뀔 수 있습니다. 선택자 업데이트가 필요할 수 있어요.
  • Apify 무료 티어는 월 $5 크레딧입니다. 가벼운 용도로는 충분합니다.
  • Self-hosted n8n이면 그냥 Puppeteer 노드 쓰면 됩니다. Cloud의 한계죠.

'IT 기술 > n8n' 카테고리의 다른 글

n8n 공부 방법 완벽 가이드: 초보자도 7일 만에 자동화 전문가 되는 법  (0) 2026.01.21
'IT 기술/n8n' 카테고리의 다른 글
  • n8n 공부 방법 완벽 가이드: 초보자도 7일 만에 자동화 전문가 되는 법
시남
시남
개발하는 사람입니다. 하고 싶은 것들 사이에서 매번 선택하는 삶을 살고 있습니다.
  • 시남
    Refactor Life like code.
    시남
  • 전체
    오늘
    어제
    • 분류 전체보기 (26)
      • IT 기술 (19)
        • Spring boot (5)
        • Claude (1)
        • AWS (5)
        • n8n (2)
      • 이야기 (3)
      • 독서 (0)
      • 개발일기 (4)
        • 1D3Q (4)
  • 블로그 메뉴

    • 홈
    • 태그
    • 미디어로그
    • 위치로그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    n8n공부방법
    자동화공부
    회고
    1인기획
    docker
    Ai
    reverse tunnel
    claudecode
    gemini
    n8n실습
    java
    기획
    root@localhost
    1D3Q
    contentcachingrequestwrapper
    개발일지
    n8n자동화예제
    도커 미러
    도커 토큰 503
    Spring Boot
    사이드프로젝트
    git@github.com
    1인개발
    도커 503
    AWS
    리버스 터널링
    인프런n8n
    Spring
    Apify
    claude marketplace
  • 최근 댓글

  • 최근 글

  • hELLO By정상우.v4.10.4 관리
시남
[n8n] Cloud 환경에서 Threads 스크래핑하기 - 삽질 기록
상단으로

티스토리툴바