diff --git a/packages/platforms/src/platforms/instagram.ts b/packages/platforms/src/platforms/instagram.ts index 2c101e3..c8a0075 100644 --- a/packages/platforms/src/platforms/instagram.ts +++ b/packages/platforms/src/platforms/instagram.ts @@ -1,40 +1,116 @@ -import { Platform } from "../types"; -import type { IGMedia } from "./instagram.d"; +import * as cheerio from "cheerio"; + +import { NormalizedPost, Platform } from "../types"; const MATCH_RE = - /^(?:https?:\/\/)?(?:[\w-]+\.)*instagram\.com\/(?:[A-Za-z0-9_.]+\/)?(p|share|reels|reel)\/(?[A-Za-z0-9-_]+)/; + /^(?:https?:\/\/)?(?:[\w-]+\.)*instagram\.com\/(?:[A-Za-z0-9_.]+\/)?(?p|share|reels|reel)\/(?[A-Za-z0-9-_]+)/; + +const PRELOADER_PREFIX = "adp_PolarisLoggedOutDesktopWWWPostRootContentQueryRelayPreloader_"; + +interface InstagramImageCandidate { + url: string; +} + +interface InstagramMedia { + __typename?: string; + code: string; + taken_at: number; + caption?: { text?: string } | null; + user: { + username: string; + full_name?: string; + profile_pic_url: string; + }; + like_count?: number; + comment_count?: number; + product_type?: string; + play_count?: number; + video_play_count?: number; + view_count?: number; + accessibility_caption?: string; + image_versions2?: { + candidates?: InstagramImageCandidate[]; + }; + video_versions?: Array<{ url: string }>; + carousel_media?: InstagramMedia[]; +} + +function normalizeType(type: string) { + if (type === "reels") return "reel"; + return type; +} + +function firstImage(raw: InstagramMedia) { + return raw.image_versions2?.candidates?.[0]?.url; +} + +function mediaURL(raw: InstagramMedia) { + const video = raw.video_versions?.[0]?.url; + if (video) return { url: video, type: "video", description: raw.accessibility_caption }; + + const image = firstImage(raw); + if (image) return { url: image, type: "photo", description: raw.accessibility_caption }; + + return null; +} + +function parseMedia(raw: InstagramMedia): NormalizedPost["media"] { + if (raw.carousel_media) { + return raw.carousel_media.map(mediaURL).filter((media) => media !== null); + } + + const media = mediaURL(raw); + if (!media) return []; + + return [media]; +} + +function parseRelayMedia(script: string) { + const data = JSON.parse(script); + const requireItems = data?.require?.[0]?.[3]?.[0]?.__bbox?.require; + if (!Array.isArray(requireItems)) return null; + + const relayRequire = requireItems.find( + (item) => + item[0] === "RelayPrefetchedStreamCache" && item[3]?.[0]?.startsWith(PRELOADER_PREFIX), + ); -export const Instagram: Platform<"Instagram", IGMedia, {}> = { + return relayRequire?.[3]?.[1]?.__bbox?.result?.data?.xig_polaris_media + ?.if_not_gated_logged_out as InstagramMedia | undefined; +} + +export const Instagram: Platform<"Instagram", InstagramMedia, {}> = { type: "Instagram", - async match(url) { + async match(url, env) { if (url.includes("share")) { const req = await fetch(url.endsWith("/") ? url : `${url}/`, { redirect: "follow", headers: { - "User-Agent": "curl/8.7.1", + "User-Agent": env?.EMBED_USER_AGENT ?? "curl/8.7.1", }, }); url = req.url; } const groups = url.match(MATCH_RE)?.groups; - return groups?.ig_shortcode ?? null; + if (!groups) return null; + + const type = normalizeType(groups.ig_type); + return `${type}/${groups.ig_shortcode}`; }, async fetch(id, env) { - const graphql = new URL(`https://www.instagram.com/api/graphql`); - graphql.searchParams.set("variables", JSON.stringify({ shortcode: id })); - graphql.searchParams.set("doc_id", "10015901848480474"); - graphql.searchParams.set("lsd", "AVqbxe3J_YA"); - - const resp = await fetch(graphql.toString(), { - method: "POST", + const [type, shortcode] = id.includes("/") ? id.split("/") : ["p", id]; + const resp = await fetch(`https://www.instagram.com/${normalizeType(type)}/${shortcode}/`, { + method: "GET", headers: { "User-Agent": env?.EMBED_USER_AGENT ?? "", - "Content-Type": "application/x-www-form-urlencoded", - "X-IG-App-ID": "936619743392459", - "X-FB-LSD": "AVqbxe3J_YA", - "X-ASBD-ID": "129477", - "Sec-Fetch-Site": "same-origin", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + "Upgrade-Insecure-Requests": "1", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", }, }); @@ -42,55 +118,59 @@ export const Instagram: Platform<"Instagram", IGMedia, {}> = { throw { code: resp.status, message: resp.statusText }; } - const { data } = (await resp.json()) as { data?: { xdt_shortcode_media?: IGMedia } }; - const media = data?.xdt_shortcode_media; + const html = await resp.text(); + const $ = cheerio.load(html); + const script = $('script[type="application/json"][data-sjs]') + .toArray() + .map((el) => $(el).text()) + .find( + (text) => text.includes("RelayPrefetchedStreamCache") && text.includes(PRELOADER_PREFIX), + ); + + if (!script) { + throw { + code: 500, + message: "Instagram page structure changed: missing data script", + }; + } + + let media: InstagramMedia | undefined | null; + try { + media = parseRelayMedia(script); + } catch { + throw { code: 500, message: "Failed to parse Instagram data" }; + } if (!media) { throw { code: 500, - message: "Instagram API returned unexpected structure", + message: "Instagram page structure changed: missing media data", }; } return media; }, async transform(raw) { - const caption = raw.edge_media_to_caption.edges[0]?.node.text; - const authorName = raw.owner.full_name || raw.owner.username; - - let media: Array<{ url: string; type: string }>; - let views: number | undefined; - - if (raw.__typename === "XDTGraphVideo") { - media = [{ url: raw.video_url, type: "video" }]; - views = raw.video_play_count; - } else if (raw.__typename === "XDTGraphSidecar") { - media = raw.edge_sidecar_to_children.edges.map(({ node }) => - node.__typename === "XDTGraphVideo" - ? { url: node.video_url, type: "video" } - : { url: node.display_url, type: "photo" }, - ); - } else { - media = [{ url: raw.display_url, type: "photo" }]; - } + const authorName = raw.user.full_name || raw.user.username; + const path = raw.product_type === "clips" ? "reel" : "p"; return { platform: this.type, author: { name: authorName, - handle: raw.owner.username, - url: `https://www.instagram.com/${raw.owner.username}/`, - avatar: raw.owner.profile_pic_url, + handle: raw.user.username, + url: `https://www.instagram.com/${raw.user.username}/`, + avatar: raw.user.profile_pic_url, }, - url: `https://www.instagram.com/p/${raw.shortcode}/`, - text: caption, - timestamp: raw.taken_at_timestamp, + url: `https://www.instagram.com/${path}/${raw.code}/`, + text: raw.caption?.text, + timestamp: raw.taken_at, stats: { - likes: raw.edge_media_preview_like.count, - comments: raw.edge_media_to_comment.count, - views, + likes: raw.like_count, + comments: raw.comment_count, + views: raw.play_count ?? raw.video_play_count ?? raw.view_count, }, - media, + media: parseMedia(raw), }; }, } as const; diff --git a/packages/platforms/src/platforms/instagram_old.ts b/packages/platforms/src/platforms/instagram_old.ts new file mode 100644 index 0000000..2c101e3 --- /dev/null +++ b/packages/platforms/src/platforms/instagram_old.ts @@ -0,0 +1,96 @@ +import { Platform } from "../types"; +import type { IGMedia } from "./instagram.d"; + +const MATCH_RE = + /^(?:https?:\/\/)?(?:[\w-]+\.)*instagram\.com\/(?:[A-Za-z0-9_.]+\/)?(p|share|reels|reel)\/(?[A-Za-z0-9-_]+)/; + +export const Instagram: Platform<"Instagram", IGMedia, {}> = { + type: "Instagram", + async match(url) { + if (url.includes("share")) { + const req = await fetch(url.endsWith("/") ? url : `${url}/`, { + redirect: "follow", + headers: { + "User-Agent": "curl/8.7.1", + }, + }); + url = req.url; + } + + const groups = url.match(MATCH_RE)?.groups; + return groups?.ig_shortcode ?? null; + }, + async fetch(id, env) { + const graphql = new URL(`https://www.instagram.com/api/graphql`); + graphql.searchParams.set("variables", JSON.stringify({ shortcode: id })); + graphql.searchParams.set("doc_id", "10015901848480474"); + graphql.searchParams.set("lsd", "AVqbxe3J_YA"); + + const resp = await fetch(graphql.toString(), { + method: "POST", + headers: { + "User-Agent": env?.EMBED_USER_AGENT ?? "", + "Content-Type": "application/x-www-form-urlencoded", + "X-IG-App-ID": "936619743392459", + "X-FB-LSD": "AVqbxe3J_YA", + "X-ASBD-ID": "129477", + "Sec-Fetch-Site": "same-origin", + }, + }); + + if (!resp.ok) { + throw { code: resp.status, message: resp.statusText }; + } + + const { data } = (await resp.json()) as { data?: { xdt_shortcode_media?: IGMedia } }; + const media = data?.xdt_shortcode_media; + + if (!media) { + throw { + code: 500, + message: "Instagram API returned unexpected structure", + }; + } + + return media; + }, + async transform(raw) { + const caption = raw.edge_media_to_caption.edges[0]?.node.text; + const authorName = raw.owner.full_name || raw.owner.username; + + let media: Array<{ url: string; type: string }>; + let views: number | undefined; + + if (raw.__typename === "XDTGraphVideo") { + media = [{ url: raw.video_url, type: "video" }]; + views = raw.video_play_count; + } else if (raw.__typename === "XDTGraphSidecar") { + media = raw.edge_sidecar_to_children.edges.map(({ node }) => + node.__typename === "XDTGraphVideo" + ? { url: node.video_url, type: "video" } + : { url: node.display_url, type: "photo" }, + ); + } else { + media = [{ url: raw.display_url, type: "photo" }]; + } + + return { + platform: this.type, + author: { + name: authorName, + handle: raw.owner.username, + url: `https://www.instagram.com/${raw.owner.username}/`, + avatar: raw.owner.profile_pic_url, + }, + url: `https://www.instagram.com/p/${raw.shortcode}/`, + text: caption, + timestamp: raw.taken_at_timestamp, + stats: { + likes: raw.edge_media_preview_like.count, + comments: raw.edge_media_to_comment.count, + views, + }, + media, + }; + }, +} as const;