Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Download and save Instagram posts as high-resolution files. Use when asked to download, save, or archive an Instagram post, reel thumbnail, or carousel. Produces saved high-res images in a named folder, with carousel slides stitched into a single PDF; supports batch downloading of multiple URLs at once.
.claude/skills/mohitagw15856-instagram-post-downloader/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-04 | ✗→✓ | ▲ Improved | 444% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 158% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 440% | 0% |
| case-12 | ✗→✓ | ▲ Improved | 421% | 0% |
| case-13 | ✗→✓ | ▲ Improved | 404% | 0% |
Downloads Instagram posts at full resolution from Instagram's CDN — no screenshots, no compression. Handles single images, carousels (multi-slide posts), and Reel cover images. For carousels, produces individual slide files plus a single stitched PDF. Supports batch URLs in one run.
Before this skill can fetch any media, you must add Instagram's CDN domain to Claude Code's allowlist:
Settings → Capabilities → Domain allowlist → Add:
*.cdninstagram.comWithout this, all CDN fetch calls will be blocked. If you see a permission error when Claude attempts a fetch to cdninstagram.com, this is the fix.
Claude will ask for these if not provided upfront:
| Input | Required | Notes | |---|---|---| | Instagram post URL(s) | Yes | One per line, or comma-separated. https://www.instagram.com/p/XXXX/ or https://www.instagram.com/reel/XXXX/ format | | Output directory | No | Defaults to ./instagram-downloads/ in the current working directory | | PDF stitch for carousels | No | Defaults to yes — produces carousel.pdf alongside individual slides | | File naming prefix | No | Optional prefix added before slide filenames, e.g. brand_ → brand_slide_01.jpg |
Batch input example:
https://www.instagram.com/p/ABC123/
https://www.instagram.com/p/DEF456/
https://www.instagram.com/p/GHI789/For each URL processed, Claude creates a folder named after the post caption (first 40 characters, sanitised — spaces become underscores, special characters stripped). If no caption is available, the folder is named after the post shortcode.
instagram-downloads/
└── this_is_the_caption_first_40_chars/
├── image.jpg
└── metadata.txtinstagram-downloads/
└── carousel_caption_first_40_chars/
├── slide_01.jpg
├── slide_02.jpg
├── slide_03.jpg
├── slide_04.jpg
├── carousel.pdf ← all slides stitched in order
└── metadata.txtinstagram-downloads/
├── first_post_caption_sanitised/
│ ├── image.jpg
│ └── metadata.txt
├── second_post_carousel_caption/
│ ├── slide_01.jpg
│ ├── slide_02.jpg
│ ├── carousel.pdf
│ └── metadata.txt
└── third_post_caption_here/
├── image.jpg
└── metadata.txtPost URL: https://www.instagram.com/p/XXXX/
Shortcode: XXXX
Type: carousel | single_image | reel
Slide count: 4 (carousel only)
Caption: [full caption text]
Username: @username
Fetched at: 2026-05-27T14:32:00Z
CDN URLs:
slide_01.jpg https://scontent.cdninstagram.com/v/...
slide_02.jpg https://scontent.cdninstagram.com/v/...Instagram Post Downloader — Batch Complete
==========================================
URLs processed: 3
Posts saved: 3
Total files: 11 (9 images + 2 PDFs)
Skipped: 0
Output dir: /Users/you/project/instagram-downloads/
Results:
✓ this_is_the_caption_first_40_chars/ 1 image
✓ carousel_caption_first_40_chars/ 4 slides → carousel.pdf
✓ third_post_caption_here/ 1 imageinstagram.com/p/, instagram.com/reel/, or instagram.com/tv/. Flag malformed URLs before proceeding../instagram-downloads/ and tell the user.Fetch the Instagram post page HTML:
GET https://www.instagram.com/p/{shortcode}/?__a=1&__d=disInstagram frequently changes its API surface. Use this fallback chain in order:
Attempt A — JSON endpoint:
https://www.instagram.com/p/{shortcode}/?__a=1&__d=disParse the JSON response. Look for graphql.shortcode_media or data.shortcode_media.
Attempt B — Embed page (most reliable):
https://www.instagram.com/p/{shortcode}/embed/captioned/Fetch this page's HTML and extract og:image meta tags and any window.__additionalDataLoaded or window.__StaticData JSON blobs embedded in <script> tags.
Attempt C — oEmbed endpoint:
https://api.instagram.com/oembed/?url=https://www.instagram.com/p/{shortcode}/&omitscript=trueThis returns thumbnail_url — useful for single images, but only gives the first frame for carousels.
Headers to include on all requests:
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36
Accept-Language: en-US,en;q=0.9
Accept: text/html,application/xhtml+xml,application/jsonFrom the fetched data, extract all high-resolution CDN URLs. Instagram CDN URLs follow these patterns:
https://scontent.cdninstagram.com/v/...jpg?...
https://scontent-lax3-1.cdninstagram.com/v/...jpg?...
https://instagram.fXXX1-1.fbcdn.net/v/...jpg?...For single image posts:
display_url or the largest display_resources entry (pick the one with the highest config_width).For carousel posts:
edge_sidecar_to_children.edges[] in the JSON. Each edge has its own node.display_url and node.display_resources[].display_resources array.For Reels:
If JSON extraction fails, fall back to scraping <meta property="og:image"> tags from the page HTML — this gives at least one image URL (the first slide or only image).
Build the folder name from the post caption:
pythonimport re def sanitise_folder_name(caption: str, shortcode: str) -> str: truncated = caption[:40] cleaned = re.sub(r'[^a-zA-Z0-9 \-]', '', truncated) underscored = re.sub(r'[\s\-]+', '_', cleaned).strip('_').lower() return underscored if underscored else shortcode
pythonimport os base_dir = "./instagram-downloads" folder_name = sanitise_folder_name(caption, shortcode) post_dir = os.path.join(base_dir, folder_name) os.makedirs(post_dir, exist_ok=True)
If a folder with that name already exists (e.g. running the same URL twice), append the shortcode to avoid collision: folder_name_SHORTCODE.
For each CDN URL, download the file with a streaming GET request:
pythonimport requests def download_file(url: str, dest_path: str) -> bool: headers = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", "Referer": "https://www.instagram.com/", } response = requests.get(url, headers=headers, stream=True, timeout=30) response.raise_for_status() with open(dest_path, "wb") as f: for chunk in response.iter_content(chunk_size=8192): f.write(chunk) return True
Name files:
image.jpgslide_01.jpg, slide_02.jpg, ... (zero-padded to 2 digits, or 3 digits if >99 slides)Detect file format from the Content-Type header or URL extension. Instagram serves JPEG for photos and may serve WebP in some cases — preserve the actual extension.
After all slides are downloaded, stitch them into a single PDF using Pillow:
pythonfrom PIL import Image def stitch_to_pdf(image_paths: list[str], output_path: str) -> None: """ Combine a list of image files into a single multi-page PDF. Each image becomes one page. Page size matches the image dimensions. """ images = [] for path in sorted(image_paths): # sort ensures slide_01, slide_02, ... order img = Image.open(path).convert("RGB") images.append(img) if not images: return first = images[0] rest = images[1:] first.save( output_path, format="PDF", save_all=True, append_images=rest, resolution=150.0, )
Save as carousel.pdf in the post folder. If Pillow is not installed, run pip install Pillow first — or instruct the user to do so.
Dependency check at start of skill:
pythontry: from PIL import Image except ImportError: print("Pillow not installed. Run: pip install Pillow") print("PDF stitching will be skipped. Individual slides will still be downloaded.") skip_pdf = True
Write a metadata.txt file into the post folder with all extracted metadata:
pythonfrom datetime import datetime, timezone def write_metadata(post_dir, post_url, shortcode, post_type, caption, username, cdn_urls): lines = [ f"Post URL: {post_url}", f"Shortcode: {shortcode}", f"Type: {post_type}", ] if post_type == "carousel": lines.append(f"Slide count: {len(cdn_urls)}") lines += [ f"Caption: {caption}", f"Username: @{username}", f"Fetched at: {datetime.now(timezone.utc).isoformat()}", "CDN URLs:", ] for filename, url in cdn_urls.items(): lines.append(f" {filename:<16} {url}") with open(os.path.join(post_dir, "metadata.txt"), "w", encoding="utf-8") as f: f.write("\n".join(lines) + "\n")
After processing all URLs, print the summary table to the terminal (format shown in Output Structure section above). Include:
| Error scenario | Action | |---|---| | URL is not an Instagram URL | Skip with message: "Skipped — not an Instagram URL: url]" | | Post is private or requires login | Skip with message: "Skipped — post is private or login required: url]" | | CDN fetch returns 403/404 | Try alternate CDN URL if available; if none, skip slide and note in metadata | | Pillow not installed | Skip PDF stitching, save slides only, note in summary | | Network timeout | Retry once after 5 seconds; if still failing, skip and log | | Folder name collision | Append shortcode suffix to folder name | | Rate limiting (429) | Wait 10 seconds and retry; log if retry also fails |
This skill covers images and carousel PDFs. For Reels video files, Claude Code cannot download video directly without a third-party tool, because Instagram's video CDN uses signed URLs and additional auth tokens.
Recommended approach for Reels:
Use yt-dlp, a maintained open-source tool:
bash# Install pip install yt-dlp # Download a Reel yt-dlp "https://www.instagram.com/reel/XXXX/" -o "%(title)s.%(ext)s" # Download to a specific folder yt-dlp "https://www.instagram.com/reel/XXXX/" \ -o "./instagram-downloads/%(uploader)s_%(id)s.%(ext)s" # Download best quality yt-dlp -f "bestvideo+bestaudio" "https://www.instagram.com/reel/XXXX/"
Claude can run this command via Bash if the user asks. yt-dlp handles the auth token extraction automatically for public Reels.
Claude should offer to write this as a standalone script (instagram_downloader.py) that the user can run independently:
python#!/usr/bin/env python3 """ Instagram Post Downloader Fetches high-res images from public Instagram posts and carousels. Requires: pip install requests Pillow """ import os import re import sys import json import time import requests from datetime import datetime, timezone from pathlib import Path try: from PIL import Image PILLOW_AVAILABLE = True except ImportError: PILLOW_AVAILABLE = False print("Warning: Pillow not installed. PDF stitching disabled. Run: pip install Pillow") HEADERS = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept-Language": "en-US,en;q=0.9", "Referer": "https://www.instagram.com/", } def extract_shortcode(url: str) -> str: match = re.search(r"instagram\.com/(?:p|reel|tv)/([A-Za-z0-9_-]+)", url) if not match: raise ValueError(f"Cannot extract shortcode from URL: {url}") return match.group(1) def fetch_post_data(shortcode: str) -> dict: """Try multiple endpoints to get post JSON data.""" # Attempt A: JSON endpoint try: url = f"https://www.instagram.com/p/{shortcode}/?__a=1&__d=dis" r = requests.get(url, headers=HEADERS, timeout=15) if r.status_code == 200: data = r.json() media = (data.get("graphql", {}).get("shortcode_media") or data.get("data", {}).get("shortcode_media")) if media: return media except Exception: pass # Attempt B: Embed page try: url = f"https://www.instagram.com/p/{shortcode}/embed/captioned/" r = requests.get(url, headers=HEADERS, timeout=15) html = r.text # Look for JSON blob in script tags matches = re.findall(r'window\.__additionalDataLoaded\([^,]+,(\{.+?\})\);', html) for blob in matches: try: data = json.loads(blob) media = (data.get("graphql", {}).get("shortcode_media") or data.get("data", {}).get("shortcode_media")) if media: return media except json.JSONDecodeError: continue except Exception: pass return {} def get_cdn_urls(media: dict) -> list[tuple[str, str]]: """Return list of (filename, cdn_url) tuples.""" results = [] media_type = media.get("__typename", "") if media_type == "GraphSidecar": edges = media.get("edge_sidecar_to_children", {}).get("edges", []) for i, edge in enumerate(edges, start=1): node = edge.get("node", {}) resources = node.get("display_resources", []) url = (max(resources, key=lambda r: r.get("config_width", 0)).get("src") if resources else node.get("display_url", "")) if url: ext = "jpg" if "jpg" in url.lower() else "webp" filename = f"slide_{i:02d}.{ext}" results.append((filename, url)) else: resources = media.get("display_resources", []) url = (max(resources, key=lambda r: r.get("config_width", 0)).get("src") if resources else media.get("display_url", "")) if url: ext = "jpg" if "jpg" in url.lower() else "webp" results.append((f"image.{ext}", url)) return results def sanitise_folder_name(caption: str, shortcode: str) -> str: truncated = caption[:40] if caption else "" cleaned = re.sub(r"[^a-zA-Z0-9 \-]", "", truncated) underscored = re.sub(r"[\s\-]+", "_", cleaned).strip("_").lower() return underscored if underscored else shortcode def download_file(url: str, dest_path: str) -> bool: r = requests.get(url, headers=HEADERS, stream=True, timeout=30) r.raise_for_status() with open(dest_path, "wb") as f: for chunk in r.iter_content(chunk_size=8192): f.write(chunk) return True def stitch_pdf(image_paths: list[str], output_path: str) -> None: if not PILLOW_AVAILABLE: return images = [Image.open(p).convert("RGB") for p in sorted(image_paths)] if images: images[0].save(output_path, format="PDF", save_all=True, append_images=images[1:], resolution=150.0) def process_url(post_url: str, base_dir: str, stitch_pdf_flag: bool) -> dict: result = {"url": post_url, "status": "ok", "files": [], "error": None} try: shortcode = extract_shortcode(post_url) media = fetch_post_data(shortcode) caption = "" username = "" if media: caption_edges = media.get("edge_media_to_caption", {}).get("edges", []) caption = caption_edges[0]["node"]["text"] if caption_edges else "" owner = media.get("owner", {}) username = owner.get("username", "") folder_name = sanitise_folder_name(caption, shortcode) post_dir = os.path.join(base_dir, folder_name) if os.path.exists(post_dir): post_dir = f"{post_dir}_{shortcode}" os.makedirs(post_dir, exist_ok=True) cdn_urls = get_cdn_urls(media) if media else [] if not cdn_urls: # Fallback: oEmbed oembed_url = f"https://api.instagram.com/oembed/?url={post_url}&omitscript=true" r = requests.get(oembed_url, headers=HEADERS, timeout=10) if r.status_code == 200: thumb = r.json().get("thumbnail_url", "") if thumb: cdn_urls = [("image.jpg", thumb)] username = r.json().get("author_name", "") downloaded_paths = [] cdn_map = {} for filename, url in cdn_urls: dest = os.path.join(post_dir, filename) download_file(url, dest) downloaded_paths.append(dest) cdn_map[filename] = url result["files"].append(filename) if stitch_pdf_flag and len(downloaded_paths) > 1 and PILLOW_AVAILABLE: pdf_path = os.path.join(post_dir, "carousel.pdf") stitch_pdf(downloaded_paths, pdf_path) result["files"].append("carousel.pdf") post_type = "carousel" if len(cdn_urls) > 1 else "single_image" write_metadata(post_dir, post_url, shortcode, post_type, caption, username, cdn_map) result["files"].append("metadata.txt") except Exception as e: result["status"] = "error" result["error"] = str(e) return result def write_metadata(post_dir, post_url, shortcode, post_type, caption, username, cdn_map): lines = [ f"Post URL: {post_url}", f"Shortcode: {shortcode}", f"Type: {post_type}", ] if post_type == "carousel": lines.append(f"Slide count: {len([k for k in cdn_map if 'slide' in k])}") lines += [ f"Caption: {caption}", f"Username: @{username}", f"Fetched at: {datetime.now(timezone.utc).isoformat()}", "CDN URLs:", ] for fn, url in cdn_map.items(): lines.append(f" {fn:<18} {url}") with open(os.path.join(post_dir, "metadata.txt"), "w", encoding="utf-8") as f: f.write("\n".join(lines) + "\n") def main(urls: list[str], base_dir: str = "./instagram-downloads", stitch: bool = True): os.makedirs(base_dir, exist_ok=True) results = [] for url in urls: url = url.strip() if not url: continue print(f"Processing: {url}") r = process_url(url, base_dir, stitch) results.append(r) time.sleep(1) # polite delay between requests # Summary ok = [r for r in results if r["status"] == "ok"] err = [r for r in results if r["status"] == "error"] total_files = sum(len(r["files"]) for r in ok) print("\nInstagram Post Downloader — Batch Complete") print("==========================================") print(f"URLs processed: {len(results)}") print(f"Posts saved: {len(ok)}") print(f"Total files: {total_files}") print(f"Errors: {len(err)}") print(f"Output dir: {os.path.abspath(base_dir)}\n") for r in results: if r["status"] == "ok": print(f" OK {r['url']}") else: print(f" ERR {r['url']} — {r['error']}") if __name__ == "__main__": if len(sys.argv) < 2: print("Usage: python instagram_downloader.py <url1> [url2] ...") sys.exit(1) main(sys.argv[1:])
Before marking the task complete, verify each item:
*.cdninstagram.com is added before any fetch attemptsconfig_width selected)slide_01, slide_02, ...)Instagram actively changes its page structure and API endpoints. If all three fetch attempts fail:
/embed/captioned/) is historically the most stable — start there.This skill is designed for public posts only. It does not support login, sessions, or private content.
Originally inspired by a skill from Frank and Diana Dovgopol (Write, Prompt, Scale) — adapted and extended for this library.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→fail | 14,012 | 4,564 | -67% | 1 | 1 | 0% | 3,165 | 7,530 | +138% | 0 | 0 | — |
case-02 | fail→fail | 15,010 | 3,325 | -78% | 1 | 1 | 0% | 3,312 | 7,292 | +120% | 0 | 0 | — |
case-03 | fail→fail | 9,691 | 16,313 | +68% | 1 | 1 | 0% | 1,934 | 8,280 | +328% | 0 | 0 | — |
case-04 | fail→pass | 7,275 | 5,984 | -18% | 1 | 1 | 0% | 1,487 | 8,095 | +444% | 0 | 0 | — |
case-05 | pass→pass | 9,468 | 3,314 | -65% | 1 | 1 | 0% | 1,630 | 7,479 | +359% | 0 | 0 | — |
case-06 | pass→pass | 6,693 | 3,582 | -46% | 1 | 1 | 0% | 1,147 | 7,528 | +556% | 0 | 0 | — |
case-07 | pass→pass | 4,120 | 1,485 | -64% | 1 | 1 | 0% | 708 | 7,004 | +889% | 0 | 0 | — |
case-08 | fail→pass | 15,444 | 4,753 | -69% | 1 | 1 | 0% | 3,045 | 7,863 | +158% | 0 | 0 | — |
case-09 | pass→pass | 6,798 | 2,339 | -66% | 1 | 1 | 0% | 1,284 | 7,205 | +461% | 0 | 0 | — |
case-10 | fail→pass | 7,458 | 2,574 | -65% | 1 | 1 | 0% | 1,335 | 7,205 | +440% | 0 | 0 | — |
case-11 | pass→pass | 9,510 | 6,562 | -31% | 1 | 1 | 0% | 1,743 | 7,995 | +359% | 0 | 0 | — |
case-12 | fail→pass | 7,819 | 2,431 | -69% | 1 | 1 | 0% | 1,383 | 7,208 | +421% | 0 | 0 | — |
case-13 | fail→pass | 7,824 | 1,498 | -81% | 1 | 1 | 0% | 1,387 | 6,996 | +404% | 0 | 0 | — |
case-14 | fail→pass | 4,726 | 3,349 | -29% | 1 | 1 | 0% | 911 | 7,481 | +721% | 0 | 0 | — |
case-20 | fail→pass | 5,607 | 3,030 | -46% | 1 | 1 | 0% | 902 | 7,290 | +708% | 0 | 0 | — |
case-15 | pass→pass | 13,621 | 3,178 | -77% | 1 | 1 | 0% | 2,299 | 7,317 | +218% | 0 | 0 | — |
case-16 | pass→pass | 10,122 | 4,700 | -54% | 1 | 1 | 0% | 1,797 | 7,646 | +325% | 0 | 0 | — |
case-17 | pass→fail | 12,295 | 3,156 | -74% | 1 | 1 | 0% | 2,092 | 7,359 | +252% | 0 | 0 | — |
case-18 | pass→pass | 14,576 | 6,944 | -52% | 1 | 1 | 0% | 2,676 | 8,206 | +207% | 0 | 0 | — |
case-19 | pass→pass | 8,992 | 2,659 | -70% | 1 | 1 | 0% | 1,656 | 7,240 | +337% | 0 | 0 | — |
case-21 | pass→pass | 5,140 | 4,427 | -14% | 1 | 1 | 0% | 849 | 7,579 | +793% | 0 | 0 | — |
case-22 | pass→pass | 2,997 | 5,299 | +77% | 1 | 1 | 0% | 526 | 7,730 | +1370% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +27 percentage points is the difference between those two pass rates over the 22 comparable cases. 2 cases got worse with the skill loaded, and they are included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.