Ship RSS to Instagram with a Tiny PHP Bot (No Plugins, No CMS)
Most blogs already syndicate updates through RSS—but Instagram is still a manual grind. In this mini-build, we turn any site (ours wasn’t WordPress) into an auto-publisher: new RSS items → square, readable title cards → posted to an Instagram Professional account via the Instagram Graph API.
The goal is practical and reproducible:
- Input: your site’s RSS feed.
- Transform: generate a clean 1080×1080 image with the post title (high contrast, safe margins, custom font).
- Output: publish to your own Instagram Business/Creator account using the two-step Graph flow (
/media→/media_publish).
Why this approach?
- No third-party schedulers: Great tools exist, but monthly fees add up. We wanted a zero-subscription path we control.
- Works with any stack: It’s plain PHP with cURL and GD. Drop-in files; no Composer required.
- Readable thumbnails: Instead of reusing wide (1200×630) OG images that shrink poorly in the grid, we render a square title card that stays legible on mobile.
What we built (at a glance)
rss_to_instagram.php— polls your RSS, de-dupes posts, creates a media container, then publishes.iggen.php— a tiny image endpoint that renders title-only square cards (configurable padding, font, colors). Returns JPEG (IG requirement).- Optional helpers used during setup (not required in production): OAuth login to get a user token, exchange for a page token, quick “test publish” scripts, and small diagnostics.
Requirements (the non-negotiables)
- Instagram Professional account (Business or Creator) linked to a Facebook Page.
- A Facebook App in Development mode, with you as Admin/Tester.
- Permissions requested at login:
instagram_basic,pages_show_list,pages_read_engagement, andinstagram_content_publish. - Use a Page Access Token when publishing (user tokens won’t publish).
Security & housekeeping
- Never commit secrets. In the sample code we’ll publish, all tokens/IDs are redacted and loaded from environment variables or a local config file ignored by Git.
- Add a simple file lock so your cron job can’t double-run.
- Consider a tiny de-dup store (file or DB) keyed by RSS
guidto avoid accidental reposts.
What you’ll learn
- How to grab a long-lived user token, derive a Page Access Token, and publish safely.
- How to generate consistently readable IG images with GD (font selection, padding, fallback).
- How to harden a quick script for real-world use (timeouts, error logging, limits).
In the next section, I’ll share the redacted code snippets and a step-by-step setup. If you can run PHP and edit a couple of files, you can ship this in under an hour—and finally let Instagram take care of itself.
Prerequisites
-
Instagram Professional (Business or Creator) account linked to a Facebook Page.
-
A Facebook App in Development mode. You (the publisher) are Admin/Tester.
-
When logging in to get tokens, request these scopes:
instagram_basic,pages_show_list,pages_read_engagement,instagram_content_publish. -
You will publish with a Page Access Token (not a user token).
File Tree
/your-project
├─ .env # never commit this
├─ rss_to_instagram.php # RSS → IG publisher
├─ rss2ig/iggen.php # 1080×1080 title-card generator (JPEG)
└─ fonts/Inter-ExtraBold.ttf # or any font with Turkish/Latin-Ext
Redacted Config (ENV)
Create a .env (or use your own secrets store). Example keys—do not hardcode:
IG_USER_ID=1784xxxxxxxxxxxxx
PAGE_ACCESS_TOKEN=EAAG-REDACTED
RSS_URL=https://example.com/feed
CAPTION_TEMPLATE={title}
Read more: {link}
#qna #news
MAX_ITEMS=3
In PHP, a tiny loader (put at the top of each script):
// env loader (very small)
foreach (@file(__DIR__.’/.env’, FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
if (strpos($line,’=’)!==false) { [$k,$v]=explode(‘=’,$line,2); $_ENV[trim($k)]=trim($v); }
}
function env($k,$d=null){ return $_ENV[$k] ?? $d; }
rss2ig/iggen.php (Title Card Generator)
-
Input:
?mode=title&title=...&brand=#0f172a&fg=#ffffff&padx=160&pady=140&font=inter -
Output: 1080×1080 JPEG with auto-wrapped title, safe margins, custom font.
Keep your TTFs in
/fontsand map them by name. Draw text withimagettftext.
Ensure your PHP GD has FreeType. ReturnContent-Type: image/jpeg.
Minimal skeleton (redacted/short):
<?php
// ==============================================
// This file is redacted and documented in English.
// Secrets are replaced with placeholders. Use env/config in production.
// ==============================================
$W = $H = 1080;
$mode = $_GET[‘mode’] ?? ‘title’;
$title = trim(// Params: title text. Optional brand/fg colors, padx/pady, font.
$_GET[‘title’] ?? ”);
$brand = $_GET[‘brand’] ?? ‘#0f172a’;
$fg = $_GET[‘fg’] ?? ‘#ffffff’;
$FONT = rtrim($_SERVER[‘DOCUMENT_ROOT’] ?? ”, ‘/’).’/fonts/Rubik-SemiBold.ttf’;
function hex2rgb($hex){ $hex=ltrim($hex,’#’); if(strlen($hex)==3){$hex=$hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2];}
return [hexdec(substr($hex,0,2)),hexdec(substr($hex,2,2)),hexdec(substr($hex,4,2))]; }
$im = // Canvas: create 1080×1080 square
imagecreatetruecolor($W,$H);
[$r,$g,$b] = hex2rgb($brand);
$bg = imagecolorallocate($im,$r,$g,$b);
// Background fill (brand color)
imagefilledrectangle($im,0,0,$W,$H,$bg);
$margin = 80;
$pad = isset($_GET[‘pad’]) ? max(20, (int)$_GET[‘pad’]) : 80;
$padx = isset($_GET[‘padx’]) ? max(20, (int)$_GET[‘padx’]) : $pad;
$pady = isset($_GET[‘pady’]) ? max(20, (int)$_GET[‘pady’]) : $pad;
$maxFS = isset($_GET[‘maxfs’])? max(24, (int)$_GET[‘maxfs’]): 78;
$minFS = isset($_GET[‘minfs’])? max(20, (int)$_GET[‘minfs’]): 36;
$maxW = $W – 2*$padx;
$maxH = $H – 2*$pady;
$lineGap= 12;
[$fr,$fgc,$fb] = hex2rgb($fg);
$color = imagecolorallocate($im,$fr,$fgc,$fb);
function wrapFit($text,$font,$maxFS,$minFS,$maxW,$maxH,$lineGap){
for($fs=$maxFS; $fs>=$minFS; $fs-=2){
$words=preg_split(‘/\s+/u’,$text); $lines=[]; $cur=”;
foreach($words as $w){
$test = trim($cur==”?$w:”$cur $w”);
$box = imagettfbbox($fs,0,$font,$test); $tw=$box[2]-$box[0];
if($tw <= $maxW){ $cur=$test; } else { $lines[]=$cur; $cur=$w; }
}
if($cur!==”) $lines[]=$cur;
$h=0; foreach($lines as $ln){ $bb=imagettfbbox($fs,0,$font,$ln); $h+=($bb[1]-$bb[7])+$lineGap; }
if($h-$lineGap <= $maxH){ return [$lines,$fs]; }
}
$text=mb_substr($text,0,140).’…’;
return wrapFit($text,$font,$minFS,$minFS,$maxW,$maxH,$lineGap);
}
[$lines,$fs] = wrapFit($title,$FONT,$maxFS,$minFS,$maxW,$maxH,$lineGap);
$totH=0; foreach($lines as $ln){ $bb=imagettfbbox($fs,0,$FONT,$ln); $totH+=($bb[1]-$bb[7])+$lineGap; }
$totH-=$lineGap;
$y = (int)(($H-$totH)/2);
$x = $padx;
foreach ($lines as $ln) {
$bb = imagettfbbox($fs, 0, $FONT, $ln);
$lh = ($bb[1] – $bb[7]);
$baseX = $x;
$baseY = $y + $lh;
$stroke = 2;
$shadow = imagecolorallocate($im, 0, 0, 0);
for ($dx = -$stroke; $dx <= $stroke; $dx++) {
for ($dy = -$stroke; $dy <= $stroke; $dy++) {
if ($dx === 0 && $dy === 0) continue;
// Draw text with TTF font (requires GD + FreeType)
imagettftext($im, $fs, 0, $baseX + $dx, $baseY + $dy, $shadow, $FONT, $ln);
}
}
imagettftext($im, $fs, 0, $baseX, $baseY, $color, $FONT, $ln);
$y += $lh + $lineGap;
}
$label = $_GET[‘label’] ?? ‘qna.com.tr’;
$labelFS = isset($_GET[‘labelfs’]) ? max(18,(int)$_GET[‘labelfs’]) : 28;
$bbL = imagettfbbox($labelFS, 0, $FONT, $label);
$labelH = $bbL[1] – $bbL[7];
$labelX = $padx;
$labelY = $H – $pady + $labelH + 4;
$shadow = imagecolorallocate($im, 0, 0, 0);
for ($dx = -1; $dx <= 1; $dx++) {
for ($dy = -1; $dy <= 1; $dy++) {
if ($dx === 0 && $dy === 0) continue;
imagettftext($im, $labelFS, 0, $labelX + $dx, $labelY + $dy, $shadow, $FONT, $label);
}
}
imagettftext($im, $labelFS, 0, $labelX, $labelY, $color, $FONT, $label);
header(‘Content-Type: image/webp’);
header(‘Cache-Control: public, max-age=604800, immutable’);
imagewebp($im,null,92);
imagedestroy($im);
Tip: If your titles are very long, cap character count (~140) and add
….
rss_to_instagram.php (Publisher)
Key points:
-
Reads RSS and limits how many items per run (e.g., 3).
-
De-dupes by
guid(file or DB). -
Creates a media container with the generated image URL → publishes.
-
Uses Page Access Token.
<!--?php // ============================================== // Redacted + English documented version. // Replace placeholders with your own values. // ============================================== // CONFIG: Instagram user id (Graph 'ig_user_id') const IG_USER_ID = 'YOUR_IG_USER_ID'; // CONFIG: Page Access Token (required for publishing) const PAGE_TOKEN = 'YOUR_PAGE_ACCESS_TOKEN'; const RSS_URL = 'https://qna.com.tr/feed'; const FALLBACK_IMG = 'https://qna.com.tr/img/og/og-image.png'; const CAPTION_TPL = "{title}\n\nRead more: {link}\n\n#qna #rss #automation"; const MAX_ITEMS = 3; // Helper: simple GET request with curl function http_get($u){ $ch = curl_init($u); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => 1,<br ?--> CURLOPT_FOLLOWLOCATION => 1,
CURLOPT_TIMEOUT => 20,
CURLOPT_USERAGENT => ‘qna-ig-bot/1.0’
]);
$r = curl_exec($ch);
curl_close($ch);
return $r;
}
// Helper: simple POST request with curl
function http_post($u, $f){
$ch = curl_init($u);
curl_setopt_array($ch, [
CURLOPT_POST => 1,
CURLOPT_RETURNTRANSFER => 1,
CURLOPT_POSTFIELDS => $f,
CURLOPT_TIMEOUT => 30,
CURLOPT_USERAGENT => ‘qna-ig-bot/1.0’
]);
$r = curl_exec($ch);
curl_close($ch);
return $r;
}
// Find an image on the article page (og:image first, fallback to first <img />)
function find_img($html){
if (preg_match(‘/property=[“\’]og:image[“\’][^>]+content=[“\’]([^”\’]+)/i’, $html, $m)) {
return html_entity_decode($m[1]);
}
if (preg_match(‘/<img[^>]+src=[“\’]([^”\’]+)/i’, $html, $m)) {
return html_entity_decode($m[1]);
}
return null;
}
// Build caption within 2200 chars
function caption($title, $link){
$c = str_replace([‘{title}’,'{link}’], [$title, $link], CAPTION_TPL);
return mb_strlen($c) > 2200 ? mb_substr($c, 0, 2190).’…’ : $c;
}
// Fetch and parse RSS
$rss = @simplexml_load_string(http_get(RSS_URL));
if (!$rss) { exit(“RSS error\n”); }
$posted = 0;
// Iterate RSS items and publish up to MAX_ITEMS
foreach ($rss->channel->item as $it) {
if ($posted >= MAX_ITEMS) break;
$title = (string)$it->title;
$link = (string)$it->link ?: (string)$it->guid;
// Resolve an image from the article (optional, we generate our own title card anyway)
$html = http_get($link);
$img = find_img($html) ?: FALLBACK_IMG;
// Generate a square title-card image via your generator endpoint (JPEG)
$image_url = ‘https://qna.com.tr/rss2ig/iggen.php?mode=title’
. ‘&title=’ . urlencode($title)
. ‘&brand=’ . urlencode(‘#0f172a’)
. ‘&fg=’ . urlencode(‘#ffffff’)
. ‘&padx=160&pady=140&maxfs=84&minfs=40’
. ‘&label=’ . urlencode(‘qna.com.tr’)
. ‘&labelfs=28’;
// Step 1: create media container (image_url + caption)
$create = http_post(“https://graph.facebook.com/v20.0/”.IG_USER_ID.”/media”, [
‘image_url’ => $image_url,
‘caption’ => caption($title, $link),
‘access_token’ => PAGE_TOKEN
]);
$cj = json_decode($create, true);
if (empty($cj[‘id’])) {
error_log(“container error: $create”);
continue;
}
// Step 2: publish the media (after container creation)
$pub = http_post(“https://graph.facebook.com/v20.0/”.IG_USER_ID.”/media_publish”, [
‘creation_id’ => $cj[‘id’],
‘access_token’ => PAGE_TOKEN
]);
$pj = json_decode($pub, true);
if (!empty($pj[‘id’])) {
echo “OK: {$title}\n”;
$posted++;
} else {
error_log(“publish error: $pub”);
}
}
echo “Done: $posted\n”;
RSS to Instagram PHP Bot Cron
Run every 30 minutes (adjust to taste):
*/30 * * * * /usr/bin/curl -fsS ‘https://yourdomain.com/rss_to_instagram.php’ >/dev/null
RSS to Instagram PHP Bot –> Troubleshooting (fast)
-
“Requires instagram_content_publish” → Re-login requesting that scope; use the new Page Access Token.
-
“This method must be called with a Page Access Token” → You’re using a user token. Exchange user → page token via
/{PAGE_ID}?fields=access_token. -
Containers fail silently → Ensure
iggen.phpreturns JPEG, 200 OK, publicly reachable. -
Overposting → Use the same
$postedvariable to check and increment; add a file lock.
FAQ — RSS to Instagram PHP Bot
1) Why won’t publishing work with a user token?
Instagram publishing requires a Page Access Token. Convert your user token via: /{PAGE_ID}?fields=access_token.
2) “Requires instagram_content_publish permission”
Re-login requesting: instagram_basic, pages_show_list, pages_read_engagement, instagram_content_publish. Then generate a new Page Access Token.
3) “This method must be called with a Page Access Token”
You’re using a user token. Publishing endpoints must use the Page Access Token.
4) /me/accounts returns empty (“No pages”)
Common causes:
-
IG is not Professional (Business/Creator) or not linked to a Facebook Page.
-
The login FB user isn’t an Admin (New Pages Experience: Full control required).
-
Missing pages_show_list in scopes.
5) What image URL formats work?
Publicly reachable HTTP(S) 200 URL; JPEG/PNG only (no WebP/private URLs/CDN 403).
6) Why 1080×1080 instead of OG 1200×630?
Square grid shrinks wide OG images; text becomes unreadable. Use iggen.php to render square title cards.
7) It posts too many items—how do I limit?
Use one counter for check and increment:
$posted=0; foreach(…) { if($posted>=MAX_ITEMS) break; if($publish_ok) $posted++; }
Also add a file lock to avoid overlapping cron runs.
8) Avoiding duplicates?
Store a hash of RSS guid (or link) after a successful publish (file or DB). Skip if seen.
9) Title text missing on images?
Ensure GD + FreeType is enabled and the TTF path exists (imagettftext() available).
10) Ideal title length/lines?
Aim for 2–4 lines in the grid. Tune padx/pady and maxfs/minfs; truncate with an ellipsis when needed.
11) Can I post videos?
Yes, via Graph API, but it needs different upload params and processing/wait steps. This guide covers JPEG photos.
12) Cross-post to the Facebook Page too?
Possible with extra permissions (e.g., pages_manage_posts). IG’s built-in auto-share isn’t guaranteed for API-posted items.
13) Token refresh cadence?
Long-lived user token ≈ 60 days. Refresh user token and regenerate a Page Access Token on a schedule.
14) Rate limits / cron best practice?
Run every 30–60 min, cap with MAX_ITEMS, log errors (error_log) and inspect container/publish JSON.
15) How do I keep secrets out of the repo?
Use a .env or secrets store. Replace hardcoded values with YOUR_… placeholders and load from env at runtime.
RSS to Instagram PHP Bot Related Links:
Github : Github RSS to Instagram
1 thought on “RSS to Instagram PHP Bot”