import type { PostType } from '@insta-monitor/core' ; import type { ScrapedPost , ScrapedProfile } from './types.js' ; /* eslint-disable @typescript-eslint/no-explicit-any -- 외부의 형식이 정해지지 않은 JSON을 파싱하기 위해 any 타입을 허용합니다 */ /** * 인스타그램의 `web_profile_info` 응답 데이터를 받아 `ScrapedProfile` 형식으로 변환합니다. * 이 함수는 교체 가능한 어댑터입니다 (PRD NFR-2): 인스타그램의 응답 구조가 변경되더라도, * 오직 이 함수만 수정하면 되도록 설계되었습니다. */ export function parseWebProfileInfo ( json : unknown ): ScrapedProfile { const user = ( json as any )?. data ?. user ; // 데이터 구조가 예상과 다를 경우 예외를 발생시킵니다. if ( ! user || typeof user . username !== 'string' ) { throw new Error ( '예상치 못한 web_profile_info 구조: data.user가 없습니다' ); } const media = user . edge_owner_to_timeline_media ?? { count : 0 , edges : [] }; const edges : any [] = Array . isArray ( media . edges ) ? media . edges : []; const posts = edges . map (( e ) => parseNode ( e . node )); return { username : user . username , displayName : user . full_name || null , followerCount : user . edge_followed_by ?. count ?? 0 , postCount : typeof media . count === 'number' ? media . count : posts . length , posts , isPrivate : user . is_private === true , }; } /** 개별 게시물(Node) 데이터를 받아 우리 서비스의 형식으로 파싱합니다. */ function parseNode ( node : any ): ScrapedPost { const type : PostType = node . product_type === 'clips' ? 'reel' : 'post' ; const shortcode : string = node . shortcode ?? node . code ?? '' ; const caption : string | null = node . edge_media_to_caption ?. edges ?.[ 0 ]?. node ?. text ?? null ; const timestamp : number = node . taken_at_timestamp ?? node . taken_at ?? 0 ; return { shortcode , type , url : `https://www.instagram.com/${ type === 'reel' ? 'reel' : 'p' }/${ shortcode }/` , thumbnail : node . display_url ?? node . thumbnail_url ?? null , caption , uploadedAt : new Date ( timestamp * 1000 ). toISOString (), viewCount : node . video_view_count ?? node . play_count ?? null , likeCount : node . like_and_view_counts_disabled ? null : ( node . edge_media_preview_like ?. count ?? node . edge_liked_by ?. count ?? node . like_count ?? null ), commentCount : node . edge_media_to_comment ?. count ?? node . comment_count ?? 0 , }; } /** * 로그인된 상태에서 가져온 GraphQL 타임라인 연결 데이터를 게시물 목록으로 변환합니다. * `web_profile_info`는 인증된 상태에서 응답이 비어있는 경우가 있으므로, * 로그인 상태에서의 수집은 이 함수가 게시물 데이터의 원천이 됩니다. */ export function parseTimelineConnection ( json : unknown ): ScrapedPost [] { const conn = ( json as any )?. data ?. xdt_api__v1__feed__user_timeline_graphql_connection ; const edges : any [] = Array . isArray ( conn ?. edges ) ? conn . edges : []; return edges . map (( e ) => parseTimelineNode ( e . node )); } // 인스타그램의 Base64 기반 알파벳 const SC_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' ; /** * 인스타그램의 숏코드(shortcode)를 숫자로 된 미디어 고유 ID(pk)로 변환합니다. * 미디어 정보 조회 API(/api/v1/media/{pk}/info/)는 숏코드가 아닌 숫자 pk를 사용합니다. * 유효하지 않은 문자가 포함되면 null을 반환합니다. */ export function shortcodeToPk ( shortcode : string ): string | null { if ( ! shortcode ) return null ; let n = 0n ; for ( const ch of shortcode ) { const d = SC_ALPHABET . indexOf ( ch ); if ( d < 0 ) return null ; n = n * 64n + BigInt ( d ); } return n . toString (); } /** * /api/v1/media/{pk}/info/ 응답에서 릴스의 현재 조회수를 추출합니다. * 타임라인 피드에서는 릴스 조회수가 null로 나올 때가 많아, * 이 함수를 통해 미디어별로 상세 조회수를 다시 조회합니다. 데이터가 없으면 null을 반환합니다. */ export function parseMediaInfoViewCount ( json : unknown ): number | null { const item = ( json as any )?. items ?.[ 0 ]; if ( ! item ) return null ; const v = item . play_count ?? item . ig_play_count ?? item . view_count ; return typeof v === 'number' ? v : null ; }