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번 호출해야 하는 셈이죠:
- Actor 실행 →
defaultDatasetId획득 - 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
- Name:
노드 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의 한계죠.