import { chromium } from 'playwright' ; import type { Browser , BrowserContext , Response , Page } from 'playwright' ; import type { IgAccount } from '@insta-monitor/core' ; import { saveSession , loadSession , profileDirFor , clearStaleSingletonLocks } from './session.js' ; import { parseWebProfileInfo , parseTimelineConnection , parseReelsClipsConnection } from './parse.js' ; import { selectReelsForViewFetch } from './viewfetch.js' ; import { dedupePostsByShortcode , filterSince , hasPostBefore , sortByUploadedAtDesc } from './pagination.js' ; import { classifyTimelineFailure , detectSoftBlock , isNotFoundPage , isPrivatePage } from './classify.js' ; import { stealthLaunchOptions , resolveChannel , hostChromeAvailable , STEALTH_INIT_SCRIPT } from './stealth.js' ; import { HUMAN_PACING , pickScroll } from './pacing.js' ; import { DISCOVERY_OVERLAY_SCRIPT } from './discovery.js' ; import { IgRateLimitError , IgPrivateAccountError } from './errors.js' ; import { toAvatarDataUrl } from './avatar.js' ; import type { CollectorOptions , ScrapedPost , ScrapedProfile } from './types.js' ; const TIMELINE_KEY = 'xdt_api__v1__feed__user_timeline_graphql_connection' ; // 릴스 탭 GraphQL 연결(PolarisProfileReelsTabContentQuery) — 프로필 타임라인에서 생략된 // 릴스별 조회수(play_count) 정보를 포함합니다. 수동으로 캡처합니다. const REELS_CLIPS_KEY = 'xdt_api__v1__clips__user__connection_v2' ; /** * 인스타그램 수집을 위한 Playwright 오케스트레이션. * * 중요 — 이 계층은 단위 테스트되지 않습니다: 실제 브라우저와 활성(로그인된) 인스타그램 세션이 필요합니다. * 순수 로직은 테스트가 완료된 `parse.ts` / `session.ts`에 있습니다. 실행 전 브라우저를 한 번 설치하세요: * pnpm -C packages/collector exec playwright install chromium * * 후속 작업(PRD에 추적됨): * - 심층 페이징: collectProfile()은 web_profile_info에서 첫 페이지만 캡처합니다(~12개 게시물). * 전체 기록을 보려면 스크롤 + 클립/피드 API 캡처가 필요합니다. * - 탐지 방지: playwright-extra + stealth로 chromium을 래핑하세요(PRD D-5). */ const IG_BASE = 'https://www.instagram.com' ; const IG_WEB_APP_ID = '936619743392459' ; // web_profile_info에 필요한 공개 인스타그램 웹 앱 ID const webProfileInfoUrl = ( username : string ) => `${ IG_BASE }/api/v1/users/web_profile_info/?username=${ encodeURIComponent ( username ) }` ; // UA를 실제 OS와 일치시키세요. — 윈도우 환경에서 Mac UA를 사용하는 것은 세션을 // IG가 봇으로 표시하도록 돕는 지문 불일치입니다. function defaultUserAgent (): string { const v = '131.0.0.0' ; const base = 'AppleWebKit/537.36 (KHTML, like Gecko)' ; if ( process . platform === 'win32' ) return `Mozilla/5.0 (Windows NT 10.0; Win64; x64) ${ base } Chrome/${ v } Safari/537.36` ; if ( process . platform === 'darwin' ) return `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ${ base } Chrome/${ v } Safari/537.36` ; return `Mozilla/5.0 (X11; Linux x86_64) ${ base } Chrome/${ v } Safari/537.36` ; } const randInt = ( min : number , max : number ): number => Math . floor ( min + Math . random () * ( max - min + 1 )); const sleep = ( ms : number ): Promise < void > => new Promise (( r ) => setTimeout ( r , ms )); /** OK가 아닌 web_profile_info 상태를 에러로 매핑합니다. 429/401/403은 IP/엔드포인트 * 속도 제한(타입 지정됨, 따라서 러너가 중단하고 UI에 정직하게 표시함)입니다. */ function webProfileError ( status : number , username : string ): Error { if ( status === 429 || status === 401 || status === 403 ) { return new IgRateLimitError ( `@${ username } IG 요청이 일시 제한됨 (web_profile_info HTTP ${ status }) — 잠시 후 자동 재시도, 재로그인 불필요.` ); } return new Error ( `web_profile_info HTTP ${ status } for ${ username }` ); } export class InstagramCollector { private browser : Browser | null = null ; private context : BrowserContext | null = null ; constructor ( private readonly opts : CollectorOptions ) {} async start (): Promise < void > { // 탐지 방지 (PRD D-5): R2 스텔스 플래그 + R4 실제 Chrome 우선순위 + R3 // 영구 프로필. 지문 근거는 stealth.ts를 참조하세요. R3 = 안정적인 // 사용자 데이터 디렉토리(launchPersistentContext)이므로 워밍업된 기기 식별값(쿠키, // IndexedDB, 기록)이 실행 후에도 유지됨 — IG는 매번 새로 로그인(체크포인트 유발)하는 대신 // 동일한 신뢰할 수 있는 기기를 보게 됩니다. const stealth = stealthLaunchOptions (); const wanted = resolveChannel ( this . opts . browserChannel ?? 'auto' , hostChromeAvailable ()); const userDataDir = profileDirFor ( this . opts . sessionPath ); // 이전 실행에서 강제 종료되면 이 프로필 디렉토리에 오래된 SingletonLock이 남습니다; 실제 Chrome은 // 그러면 실행을 거부하고 조용히 번들된 Chromium(더 약한 지문)으로 대체됩니다. // 프로세스 간 수집 잠금은 다른 실행이 프로필을 사용하지 않음을 보장하므로, 이를 지웁니다. clearStaleSingletonLocks ( this . opts . sessionPath ); // 컨텍스트 옵션은 영구 실행으로 결합됩니다(별도의 브라우저는 없음). // --headless=new (오래된 헤드리스 셸이 아님)는 봇 감지가 덜 됩니다. const launch = { headless : this . opts . headless ?? false , args : stealth . args , ignoreDefaultArgs : stealth . ignoreDefaultArgs , slowMo : HUMAN_PACING . slowMoMs , // 모든 작업 전 300ms — CDP 레벨에서 사람과 유사함(경쟁사 동일) // 86행부터 계속 locale : 'ko-KR' , userAgent : defaultUserAgent (), viewport : { width : 1280 , height : 800 }, }; let usedChannel = wanted ; try { this.context = await chromium . launchPersistentContext ( userDataDir , { channel : wanted , ... launch }); } catch ( err ) { // 호스트 Chrome이 없거나 실행할 수 없는 경우 -> 앱과 함께 제공되는 번들 Chromium으로 // 대체합니다. 따라서 채널 선택이 수집을 중단시키지 않습니다. if ( wanted === 'chrome' ) { console . warn ( `[browser] channel 'chrome' failed (${ err instanceof Error ? err . message : String ( err ) }); 번들 Chromium으로 대체합니다` ); usedChannel = 'chromium' ; this.context = await chromium . launchPersistentContext ( userDataDir , { channel : 'chromium' , ... launch }); } else { throw err ; } } this.browser = this.context.browser (); // 영구 컨텍스트의 경우 null일 수 있음; stop()은 컨텍스트를 닫음 // 진단: 실제로 어떤 엔진이 실행되었는지 기록 — 실제 Google Chrome (channel:'chrome', // 강력한 탐지 방지 지문) vs 번들 Chromium (더 약하고 봇 탐지에 취약함). // 이 정보가 없으면 성공적인 실행도 엔진에 대한 신호를 주지 않으므로, 번들 Chromium에 // 조용히 갇힌 사용자는 로그에서 실제 Chrome을 사용하는 사용자와 똑같이 보입니다. 최선의 노력. try { const ver = this.browser ?. version (); console . log ( `[browser] launched: ${ usedChannel === 'chrome' ? 'real Google Chrome' : 'bundled Chromium' }` + ` (channel=${ usedChannel }${ ver ? `, v${ ver }` : '' }, headless=${ launch . headless })` , ); } catch { /* version()은 최선의 노력이므로 — 진단 라인 때문에 start()를 중단시키지 마세요 */ } // 이중 확인: 모든 페이지 시작 시 navigator.webdriver를 제거합니다. await this.context.addInitScript ( STEALTH_INIT_SCRIPT ); // 마이그레이션 브리지: 완전히 새로운 영구 프로필에는 쿠키가 없습니다. 만약 아직 // 저장된 storageState(R3 이전, 또는 다른 곳에서 새로 고침된 세션)가 있다면, // 기존 사용자가 강제 재로그인 없이 로그인 상태를 유지하도록 쿠키를 심어줍니다. 앞으로는 // 프로필이 자체 ID를 유지하므로 이 작업은 아무것도 하지 않습니다(no-op). await this.seedProfileFromStorageState (); } /** 프로필에 아직 인증 쿠키가 없을 때 저장된 storageState에서 새로운 영구 프로필을 생성합니다. * — 기존 사용자가 R3 업그레이드 전반에 걸쳐 로그인 상태를 유지하도록 합니다. */ private async seedProfileFromStorageState (): Promise < void > { try { if ( await this.hasAuthCookie ()) return ; // 프로필이 이미 활성화됨 const state = loadSession ( this.opts.sessionPath ); const cookies = state ?. cookies ; if ( Array . isArray ( cookies ) && cookies . length > 0 ) { await this.ctx (). addCookies ( cookies as Parameters < BrowserContext [ 'addCookies' ] > [ 0 ]); console . log ( '[session] 저장된 storageState에서 영구 프로필 생성 (R3 마이그레이션)' ); } } catch ( e ) { console . warn ( `[session] 프로필 생성 건너뜀: ${ e instanceof Error ? e . message : String ( e ) }` ); } } /** * 인스타그램 로그인 페이지를 열고 사용자가 로그인을 완료할 때까지(최대 5분) 대기합니다. * 결과 세션을 저장하여 이후 실행에서 재사용할 수 있도록 합니다. */ async login (): Promise < void > { const page = await this.ctx (). newPage (); // 릴스 탭 GraphQL 연결(PolarisProfileReelsTabContentQuery) — 프로필 타임라인에서 생략된 // 릴스별 조회수(play_count) 정보를 포함합니다. 수동으로 캡처합니다. // 릴스 탭 방문 실패 시 에러 로그 기록 // 첫 번째 클립 배치를 기다린 후, 최근 릴스를 로드하기 위해 사람의 속도로 몇 번의 스크롤을 수행합니다. // 사용자가 릴스 탭 수집 중에 '중단'을 누르면 루프 탈출 // 우리가 관심 있는 모든 최근 릴스를 가져왔다면 루프 탈출 // web_profile_info를 위한 실행 단위별 회로 차단기(Circuit breaker). // 차단된 계정에서 인스타그램은 약 100% 확률로 429 에러를 반환하며, // 팔로워/게시물 수만 제공할 뿐입니다(게시물 정보는 타임라인 수집에서 가져옴). // 따라서 하루에 500번씩 호출하는 것은 아무런 성과 없이 차단 신호만 주는 행위입니다. // 연속적인 실패가 계속되면 이번 실행 동안에는 이 기능을 건너뜁니다. // 성공 시 다시 활성화되므로, 건강한 계정은 절대 이 차단기에 걸리지 않습니다. // 598행부터 // 소란스러운 실패 처리 (조용한 0값은 제외). UI/워커가 반응할 수 있도록 분류: // 302→홈 리다이렉트는 일시적 제한(쿨다운, 재로그인 불필요); 로그인 차단은 세션 // 문제(재로그인 필요); 그 외 나머지는 일반적인 형식 오류입니다. // rawCount = 윈도우 필터링 전 IG가 실제로 제공한 게시물 수 — 호출자가
        // "채널이 오래됨"(raw>0, windowed 0)과 "IG가 아무것도 제공하지 않음"(raw 0)을 구분하게 함.