用 API 下载 Instagram 图片和视频(Python & Node.js 实战)

你想把一条公开的 Instagram 图片、视频、Reels 或轮播图用程序保存到本地——而不是右键另存。难点很常见:Instagram 的媒体链接是带签名、会过期的,藏在 JS 动态渲染的页面里;官方 Meta Graph API 又只给你自己账号的媒体。自己写爬虫则要起浏览器、每次页面一改就得重新逆向。

本文用 RapidAPI 上的 Instagram Cheapest API 当中间层:你按短码(shortcode)请求一条帖子,或按 user_id 请求整个账号的媒体,它返回 Instagram 的原始 JSON(里面带着每张图、每个视频的 CDN 链接),然后你把这些链接直接下载到本地。不用浏览器,不用代理。

读完你会有一段可直接照抄的代码,能:

下载的原理:两次独立的请求

整个过程是两次不同的 HTTP 请求,务必在脑子里把它们分开:

  1. 取元数据(用你的 API Key)。你带着 x-rapidapi-key 调用 Instagram Cheapest API,返回的是带 CDN 链接的 Instagram 原始 JSON。
  2. 下载字节(不用 API Key)。那些 CDN 链接指向 Instagram 自己的服务器(*.cdninstagram.com / fbcdn.net)。你用普通 GET 直接请求——不要带 RapidAPI 的请求头——把响应体写进文件。

两个要点先记住:这些 CDN 链接带签名、会过期(常常几小时内失效),所以取到后尽快下载,别把链接存下来下周再用。另外,下载字节走的是 Instagram CDN 而不是本 API,所以不消耗你的 RapidAPI 请求额度

准备工作

在 RapidAPI 后台拿到 x-rapidapi-key。每次 API 请求都带两个头:x-rapidapi-key(你的 key)和 x-rapidapi-host: instagram-cheapest.p.rapidapi.com

第一步:搭好客户端

标准模板。基础地址是 https://instagram-cheapest.p.rapidapi.com/api/v1/instagram,所有接口都是 GET

import os
import requests

API_KEY = os.environ.get("RAPIDAPI_KEY", "你的_RAPIDAPI_KEY")
HOST = "instagram-cheapest.p.rapidapi.com"
BASE_URL = f"https://{HOST}/api/v1/instagram"

HEADERS = {
    "x-rapidapi-key": API_KEY,
    "x-rapidapi-host": HOST,
}


def get(path, params=None):
    """请求 Instagram Cheapest API,返回原始 JSON。"""
    resp = requests.get(f"{BASE_URL}/{path}", headers=HEADERS,
                        params=params, timeout=30)
    resp.raise_for_status()
    return resp.json()

注意 30 秒超时:本 API 返回实时、未缓存的数据,是从 Instagram 现拉的,所以一次调用要几秒钟。给足时间,别太早失败。

第二步:按短码抓取单条帖子或 Reel

每条 Instagram 帖子和 Reel 都有一个短码(shortcode)——就是 URL 里 /p//reel//tv/ 后面那串。比如 https://www.instagram.com/p/Cabc123XYz/ 的短码是 Cabc123XYzmedia_by_code2 接口用 code 查询参数接收它:

import json

def get_post(code):
    return get("media_by_code2", params={"code": code})


post = get_post("Cabc123XYz")   # 换成真实短码
print(json.dumps(post, indent=2, ensure_ascii=False)[:2000])

一定先打印原始响应。本 API 原样透传 Instagram 返回的内容,所以结构是 Instagram 的(不是我们固定的),而且单图、视频/Reel、轮播图的结构会不一样。打印一次就能看清你关心的那种帖子里媒体链接放在哪。

第三步:从 JSON 里取出媒体链接

与其硬编码一条脆弱的路径(如 post["items"][0]["video_versions"][0]["url"]),不如遍历整棵 JSON 树,把找到的每个媒体链接都收集起来。Instagram 把图片候选放在 image_versions2.candidates、把视频不同清晰度放在 video_versions(每个都带 width,方便挑最高清的)。因为是递归遍历,它能自动处理轮播图——每张图各自带着自己的版本信息:

def find_media(node, found=None):
    """递归收集原始 Instagram JSON 里的图片/视频链接。

    返回一个列表,每个媒体(图或视频)一项,
    各取其可用的最高清链接。
    如果哪天 Instagram 改了结构,对照你自己打印的 JSON 核对下面的 key。
    """
    if found is None:
        found = []

    if isinstance(node, dict):
        # 视频项:video_versions 是不同清晰度的列表,带 width。
        videos = node.get("video_versions")
        if isinstance(videos, list) and videos:
            best = max(videos, key=lambda v: v.get("width", 0) or 0)
            if best.get("url"):
                found.append({"type": "video", "url": best["url"]})

        # 图片项:image_versions2.candidates 是不同尺寸的列表。
        iv = node.get("image_versions2")
        if isinstance(iv, dict):
            candidates = iv.get("candidates") or []
            # 只有当该节点不是视频时,才当作独立图片
            # (视频也带一张封面图,我们不想保存它)。
            if candidates and not (isinstance(videos, list) and videos):
                best = max(candidates, key=lambda c: c.get("width", 0) or 0)
                if best.get("url"):
                    found.append({"type": "photo", "url": best["url"]})

        for value in node.values():
            find_media(value, found)

    elif isinstance(node, list):
        for item in node:
            find_media(item, found)

    return found


media = find_media(post)
for m in media:
    print(m["type"], m["url"][:80], "...")

图片那段的判断很关键:视频项也会带一张封面/缩略图放在 image_versions2 里,你通常不想把它当成单独的「图片」保存。当同一节点已有 video_versions 时跳过图片,就能保证每张轮播图只出一个干净文件。

第四步:把文件下载到本地

现在是第二次 HTTP 请求。直接请求 CDN 链接——不带 RapidAPI 请求头——把字节流式写入文件。扩展名按媒体类型决定:

import os

def download_media(media, out_dir="downloads", prefix="ig"):
    os.makedirs(out_dir, exist_ok=True)
    saved = []
    for i, m in enumerate(media):
        ext = "mp4" if m["type"] == "video" else "jpg"
        path = os.path.join(out_dir, f"{prefix}_{i}.{ext}")
        # 这里不带 API key —— 这是 Instagram 的公开 CDN。
        with requests.get(m["url"], timeout=60, stream=True) as r:
            r.raise_for_status()
            with open(path, "wb") as f:
                for chunk in r.iter_content(chunk_size=8192):
                    f.write(chunk)
        saved.append(path)
        print(f"已保存 {path}")
    return saved


# 单条帖子的完整流程:
post = get_post("Cabc123XYz")
download_media(find_media(post), prefix="Cabc123XYz")

这就是整个单帖下载器:取 JSON → 找链接 → 流式存盘。轮播图会出多个文件,Reel 出一个 .mp4。因为 CDN 链接会过期,第二步取到后请紧接着跑第四步,别缓存链接。

第五步:批量下载某用户名下的全部帖子

要抓整个账号,需要数字 user_id(媒体接口不接受用户名)。先用 userinfo 解析一次,再翻页请求 user_media——每页约 12 条,响应顶层会回传 next_max_idmore_available 用于翻页:

import time

def get_user_id(username):
    profile = get(f"user/{username}")
    # key 以你打印的 profile JSON 为准。
    return profile.get("id") or profile.get("pk")


def download_profile(username, max_pages=3):
    user_id = get_user_id(username)
    next_max_id, page = None, 0

    while page < max_pages:
        params = {"user_id": user_id}
        if next_max_id:
            params["next_max_id"] = next_max_id

        resp = get("user_media", params=params)
        media = find_media(resp)
        print(f"第 {page + 1} 页:找到 {len(media)} 个媒体文件")
        download_media(media, out_dir=f"downloads/{username}",
                       prefix=f"{username}_p{page}")

        # 翻页由响应驱动,不靠假设。
        if not resp.get("more_available"):
            break
        next_max_id = resp.get("next_max_id")
        if not next_max_id:
            break
        page += 1
        time.sleep(1)   # 友好一点;免费档约 1 次/秒


download_profile("nike", max_pages=2)

每翻一页算一次计费请求(下载字节走 CDN,免费)。time.sleep(1) 帮你卡在免费档约 1 次/秒的限速内,付费档可以加快。只想要 Reels?把 user_media 换成 user_reels 即可(它用 after 游标翻页,游标取自响应里的 page_info.end_cursor,而不是 next_max_id)。

Node.js 版

同样的两步流程,用 Node 18+ 内置的 fetchfs。递归找链接的思路完全一致:

import { writeFile, mkdir } from "node:fs/promises";

const API_KEY = process.env.RAPIDAPI_KEY || "你的_RAPIDAPI_KEY";
const HOST = "instagram-cheapest.p.rapidapi.com";
const BASE_URL = `https://${HOST}/api/v1/instagram`;

async function get(path, params = {}) {
  const qs = new URLSearchParams(params).toString();
  const url = `${BASE_URL}/${path}${qs ? "?" + qs : ""}`;
  const res = await fetch(url, {
    headers: { "x-rapidapi-key": API_KEY, "x-rapidapi-host": HOST },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status} for ${path}`);
  return res.json();
}

// 递归收集最高清的图片/视频链接。
function findMedia(node, found = []) {
  if (Array.isArray(node)) {
    node.forEach((item) => findMedia(item, found));
  } else if (node && typeof node === "object") {
    const videos = node.video_versions;
    if (Array.isArray(videos) && videos.length) {
      const best = videos.reduce((a, b) => ((b.width || 0) > (a.width || 0) ? b : a));
      if (best.url) found.push({ type: "video", url: best.url });
    }
    const cands = node.image_versions2?.candidates;
    if (Array.isArray(cands) && cands.length && !(Array.isArray(videos) && videos.length)) {
      const best = cands.reduce((a, b) => ((b.width || 0) > (a.width || 0) ? b : a));
      if (best.url) found.push({ type: "photo", url: best.url });
    }
    Object.values(node).forEach((v) => findMedia(v, found));
  }
  return found;
}

async function downloadMedia(media, dir = "downloads", prefix = "ig") {
  await mkdir(dir, { recursive: true });
  for (let i = 0; i < media.length; i++) {
    const ext = media[i].type === "video" ? "mp4" : "jpg";
    const res = await fetch(media[i].url); // 不带 API 头 —— 公开 CDN
    const buf = Buffer.from(await res.arrayBuffer());
    const path = `${dir}/${prefix}_${i}.${ext}`;
    await writeFile(path, buf);
    console.log(`已保存 ${path}`);
  }
}

// 按短码下载单条帖子:
const post = await get("media_by_code2", { code: "Cabc123XYz" });
await downloadMedia(findMedia(post), "downloads", "Cabc123XYz");

curl + jq 快速一行

只想在终端里看一眼链接的话,用 curl 取 JSON、再用 jq 挖出视频链接:

curl -s "https://instagram-cheapest.p.rapidapi.com/api/v1/instagram/media_by_code2?code=Cabc123XYz" \
  -H "x-rapidapi-key: $RAPIDAPI_KEY" \
  -H "x-rapidapi-host: instagram-cheapest.p.rapidapi.com" \
  | jq -r '.. | .video_versions? // empty | .[0].url'

# 然后下载它打印出来的链接:
curl -o reel.mp4 "<把上面的-cdn-链接粘到这里>"

jq 里的 .. 递归下降跟上面的递归遍历是一个意思——不用知道确切路径,就能在整棵树里找到 video_versions

完整脚本(复制即用)

不想自己拼?把下面整段存成 download_media.py,设好 RAPIDAPI_KEY 就能直接跑。它把上面所有步骤合在一起,还支持命令行参数:传短码下单条,传 @用户名 下整个账号,直接粘 URL 也行。

#!/usr/bin/env python3
"""按短码或用户名下载公开的 Instagram 图片和视频(Instagram Cheapest API)。

安装与配置:
    pip install requests
    export RAPIDAPI_KEY='你的key'      # 在 RapidAPI 页面获取

用法:
    python3 download_media.py Cabc123XYz                 # 按短码下单条帖子/Reel
    python3 download_media.py @nike                      # 下整个账号
    python3 download_media.py @nike --pages 5            # 多翻几页
    python3 download_media.py https://www.instagram.com/p/Cabc123XYz/   # 直接粘 URL

文件存到 ./downloads/。注意:Instagram CDN 链接带签名会过期,所以脚本取到 JSON 后立即下载。
"""
import os
import sys
import json
import time
import argparse
import requests

HOST = "instagram-cheapest.p.rapidapi.com"
BASE_URL = f"https://{HOST}/api/v1/instagram"
API_KEY = os.environ.get("RAPIDAPI_KEY", "")
HEADERS = {"x-rapidapi-key": API_KEY, "x-rapidapi-host": HOST}


def get(path, params=None):
    resp = requests.get(f"{BASE_URL}/{path}", headers=HEADERS, params=params, timeout=30)
    resp.raise_for_status()
    return resp.json()


def find_media(node, found=None):
    """递归收集最高清的图片/视频链接,自动适配单图、Reel 和轮播图。"""
    if found is None:
        found = []
    if isinstance(node, dict):
        videos = node.get("video_versions")
        if isinstance(videos, list) and videos:
            best = max(videos, key=lambda v: v.get("width", 0) or 0)
            if best.get("url"):
                found.append({"type": "video", "url": best["url"]})
        iv = node.get("image_versions2")
        if isinstance(iv, dict):
            candidates = iv.get("candidates") or []
            if candidates and not (isinstance(videos, list) and videos):
                best = max(candidates, key=lambda c: c.get("width", 0) or 0)
                if best.get("url"):
                    found.append({"type": "photo", "url": best["url"]})
        for value in node.values():
            find_media(value, found)
    elif isinstance(node, list):
        for item in node:
            find_media(item, found)
    return found


def download_media(media, out_dir="downloads", prefix="ig"):
    os.makedirs(out_dir, exist_ok=True)
    saved = []
    for i, m in enumerate(media):
        ext = "mp4" if m["type"] == "video" else "jpg"
        path = os.path.join(out_dir, f"{prefix}_{i}.{ext}")
        with requests.get(m["url"], timeout=60, stream=True) as r:   # 公开 CDN,无需 API key
            r.raise_for_status()
            with open(path, "wb") as f:
                for chunk in r.iter_content(chunk_size=8192):
                    f.write(chunk)
        print(f"  已保存 {path}")
        saved.append(path)
    return saved


def download_post(code):
    print(f"抓取帖子 {code} ...")
    post = get("media_by_code2", params={"code": code})
    media = find_media(post)
    print(f"找到 {len(media)} 个媒体文件")
    return download_media(media, prefix=code)


def download_profile(username, max_pages=3):
    profile = get(f"user/{username}")
    user_id = profile.get("id") or profile.get("pk")
    if not user_id:
        print("没找到 user_id,原始资料(前 1KB):")
        print(json.dumps(profile, indent=2, ensure_ascii=False)[:1000])
        return []
    print(f"{username} -> user_id {user_id}")
    saved, next_max_id, page = [], None, 0
    while page < max_pages:
        params = {"user_id": user_id}
        if next_max_id:
            params["next_max_id"] = next_max_id
        resp = get("user_media", params=params)
        media = find_media(resp)
        print(f"第 {page + 1} 页:{len(media)} 个媒体文件")
        saved += download_media(media, out_dir=f"downloads/{username}",
                                prefix=f"{username}_p{page}")
        if not resp.get("more_available"):
            break
        next_max_id = resp.get("next_max_id")
        if not next_max_id:
            break
        page += 1
        time.sleep(1)   # 卡在免费档约 1 次/秒
    return saved


def parse_target(target):
    """把输入解析成 ('post', 短码) 或 ('profile', 用户名)。"""
    t = target.strip().rstrip("/")
    if "instagram.com" in t:
        parts = [p for p in t.split("/") if p]
        for kw in ("p", "reel", "reels", "tv"):
            if kw in parts:
                return "post", parts[parts.index(kw) + 1]
        return "profile", parts[-1].lstrip("@")
    if t.startswith("@"):
        return "profile", t[1:]
    return "post", t


def main():
    parser = argparse.ArgumentParser(description="下载公开的 Instagram 媒体")
    parser.add_argument("target", help="短码、@用户名 或完整 instagram.com 链接")
    parser.add_argument("--pages", type=int, default=3, help="下账号时最多翻几页(默认 3)")
    args = parser.parse_args()

    if not API_KEY:
        sys.exit("先设置 RAPIDAPI_KEY:  export RAPIDAPI_KEY='你的key'")

    kind, value = parse_target(args.target)
    if kind == "profile":
        download_profile(value, max_pages=args.pages)
    else:
        download_post(value)


if __name__ == "__main__":
    main()

Node.js 用户:把上面「Node.js 版」那段补上同样的 parse_target / main 即可,完整版结构一致。

费用

只有取元数据的调用计费;真正的媒体字节由 Instagram CDN 免费提供。按请求计费,量越大单价越低:Mega 档每千次低至 $0.10、Ultra 档 $0.11、Pro 档 $0.13,另有免费 Basic 档(每月 30 次)供原型验证。由于一页 user_media 约返回 12 条帖子,下载一个账号的 1,000 条帖子大约只需 ~85 次 API 调用——在顶档连一分钱请求费都不到。各档另含每月 10 GB 的 API 流量(指响应 JSON,不含媒体下载),超出按 $0.001/MB 计费。

小结

用程序下载 Instagram 媒体,归根结底是干净的两步:用 Instagram Cheapest API 把短码或用户名变成原始 JSON,遍历 JSON 收集 CDN 链接,再用普通 GET 把链接流式存盘。递归查找器让你保持诚实——它能适配单图、Reels 和轮播图,而不硬编码脆弱路径;黄金法则照旧:先打印 JSON,以你实际看到的为准。

想动手做下载器?从免费档开始:

在 RapidAPI 上免费开始 →

合规说明:本 API 仅返回公开的 Instagram 数据。下载并使用他人的媒体可能受版权和 Instagram 服务条款限制——只下载你有权使用的内容,并遵守适用法律(GDPR/CCPA)。本产品与 Meta/Instagram 无隶属或背书关系。

今天就开始构建

Basic 免费档每月 30 次请求,无需承诺。

在 RapidAPI 上开始