Your archive has three hundred posts in it and most of them stopped getting traffic the week after they went up. The idea behind bulk posting has not changed since the iMacros era: pull every URL and title once, turn them into a queue, and drip them back into your feed for a year.
The arithmetic behind that idea changed in April 2026. X's own pricing page charges $0.015 for a post created through the API and $0.200 for a post that contains a URL. Every post in a blog-promotion queue contains a URL. That is a 13x premium on the single thing this workflow exists to do.
This guide covers the whole pipeline: extracting the archive, composing the queue, and publishing it either through a scheduling tool or through the API. Prices, limits and policy quotes below were read from X's own documentation, and from each vendor's own pricing page, on 13 August 2026. It also says plainly which parts of the standard advice have moved to the wrong side of X's rules, including advice this article used to give.
What broke between 2013 and now
The original version of this trick was a browser macro. You loaded your archive index, recorded a loop that clicked each post's tweet button, and let it run overnight. Progress Software discontinued iMacros on 30 November 2023, and the domain no longer serves a product page. The technique died before the tool did: X's composer is a JavaScript application that rewrites its own DOM, and a recorded click sequence survives about one deploy.
Three more things went away, and each one invalidates a chunk of older tutorials.
The free API stopped being free, twice. Twitter's open API ended in 2023. What replaced it was a tier system, and what replaced the tiers is a meter. X launched pay-per-use pricing on 6 February 2026, built on prepaid credits bought in a developer console. The same announcement offered "Recently active Legacy Free tier users" a one-time $10 voucher, which is the clearest statement anyone has made that the free tier is now legacy. At $0.20 a link post, that voucher buys fifty posts.
Zapier's Twitter integration is gone and has been for three years. Zapier's own notice reads: "Due to Twitter's decision to change its API policy and pricing, Zapier's current Twitter integration has stopped working (effective August 31, 2023)." New Zaps were blocked from 18 August 2023, and view-only access ended that September. Any tutorial that tells you to wire an RSS feed into Zapier and point it at Twitter was written before that and never revisited. n8n still ships an X node, though several of its listed operations no longer work on self-serve access: the 16 April 2026 changelog entry removed Following, Likes and Quote-Posts from all self-serve tiers.
Replies stopped being a growth channel. Since 23 February 2026, programmatic replies through POST /2/tweets are permitted only when the original post's author has summoned the replier. The "reply to your own post with the link" pattern is no longer something a script can do on your behalf.
The workflow in three stages
Strip away the tooling and the shape is the same as it always was:
- Extract: collect every post's URL and title into a file with two columns.
- Compose: turn each row into post text, with a link and whatever tracking you want.
- Publish: push the queue out over weeks, either through a scheduler or through the API.
Stage one is a scraping problem and it is the part that actually needs care. Stage two is fifteen lines of Python. Stage three is a decision about money and risk, and the answer is no longer the obvious one.
Stage 1: Extract your blog post archive
You need a clean list of URLs and titles for everything worth promoting. Three sources, easiest first.
Your sitemap
Almost every CMS publishes an XML sitemap. WordPress has done it in core since version 5.5, shipped in August 2020, which exposes a sitemap index at /wp-sitemap.xml with per-type files underneath it and a default of 2,000 entries per file. Yoast replaces that with its own index. Older guides that send you straight to /post-sitemap.xml are describing one plugin's layout, not a standard.
The sitemaps protocol caps a single file at 50,000 URLs and 50MB uncompressed, so any blog past that size hands you an index of indexes. Handle the nesting and the gzip in the fetcher, once:
# Python 3.13, requests 2.34.2, lxml 6.1.1 (versions current on 13 August 2026)
import gzip
import requests
from lxml import etree
NS = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"}
HEADERS = {"User-Agent": "archive-collector/1.0 (+https://yourblog.com/)"}
def fetch_xml(url):
r = requests.get(url, headers=HEADERS, timeout=30)
r.raise_for_status()
body = r.content
if body[:2] == b"\x1f\x8b": # gzipped, whatever the extension says
body = gzip.decompress(body)
return etree.fromstring(body)
def collect(entry):
root = fetch_xml(entry)
children = [loc.text for loc in root.findall(".//s:sitemap/s:loc", NS)]
if children: # an index, not a sitemap
out = []
for child in children:
out.extend(collect(child))
return out
return [loc.text for loc in root.findall(".//s:url/s:loc", NS)]
urls = collect("https://yourblog.com/wp-sitemap.xml")
print(len(urls), "URLs found")Two details that bite. etree.fromstring wants bytes when the document carries an XML declaration, so pass r.content and never r.text. And a sitemap lists every indexable URL, which includes tag archives, author pages and your privacy policy. Filter on the path prefix your posts live under before you count anything. Our deeper walkthroughs on parsing a sitemap and XML parsing in Python cover the namespace handling if your feed is stranger than the one above.
Your RSS feed
Most blogs expose /feed/ or /rss.xml. The advantage over a sitemap is that a feed already carries the title and description, so one pass gives you the copy as well as the link. Our guide to parsing RSS feeds covers the format end to end; the short version runs on feedparser 6.0.14, released 30 July 2026:
import feedparser
feed = feedparser.parse("https://yourblog.com/feed/")
rows = [(e.title, e.link) for e in feed.entries]
print(len(rows), "entries from", feed.feed.get("title"))A feed shows the last ten or twenty posts, not the archive. WordPress will page it with ?paged=2 until it runs out, but plenty of platforms will not. Treat the feed as a title source and the sitemap as the URL source, then join them on the link.
Scraping the archive directly
With no usable sitemap or feed, or when the archive belongs to somebody else, scrape the archive pages and follow the pagination yourself with CSS selectors:
# requests 2.34.2, beautifulsoup4 4.15.0
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
seen, rows = set(), []
page = "https://yourblog.com/blog/"
while page:
html = requests.get(page, timeout=30).text
soup = BeautifulSoup(html, "html.parser")
for a in soup.select("article h2 a[href]"):
url = urljoin(page, a["href"])
if url in seen:
continue
seen.add(url)
rows.append((a.get_text(strip=True), url))
nxt = soup.select_one("a[rel=next], a.next")
page = urljoin(page, nxt["href"]) if nxt else NoneThe seen set matters more than it looks. Archive pagination on a live blog shifts under you: publish a post mid-crawl and every listing slides by one, so page three repeats an entry from page two. Deduplicate on the URL, not on the title.
Whatever the source, write posts.csv with title,url and a header row. That file is the fuel for everything after this.
Stage 2: Compose the posts
A bare title plus a link works. Templating makes the feed look deliberate instead of mechanical, and it is also where the only real character-counting trap lives.
Every URL costs exactly 23 characters. X wraps links in t.co and, per its own character counting documentation, "a URL in a Tweet is 23 characters, even if the length of the URL would normally be shorter." The limit is still 280 for a standard account. An X Premium subscription raises it to 25,000 characters, and the cheapest tier is $3 a month on the web.
That has a direct consequence the naive version of this script gets wrong. Counting len(text) against 280 measures the raw URL, not the 23 characters X will bill you for. The UTM string the earlier version of this guide pasted runs to 60 characters, costs nothing against your limit, and yet pushes a perfectly valid post out of a len(text) <= 280 filter. Earlier versions of this article included that filter. It silently dropped the longest-titled posts, which are usually the ones worth promoting.
import csv, re
URL_RE = re.compile(r"https?://\S+")
TCO = 23 # every link counts as 23, long or short
LIMIT = 280
def posted_length(text):
return len(URL_RE.sub("x" * TCO, text))
TEMPLATES = [
"{title}\n\n{url}",
"From the archive: {title}\n\n{url}",
"Still the version we hand people who ask: {title}\n\n{url}",
]
def compose(title, url, i):
utm = "?utm_source=x&utm_medium=social&utm_campaign=archive"
return TEMPLATES[i % len(TEMPLATES)].format(title=title, url=url + utm)
with open("posts.csv", newline="") as src, open("queue.csv", "w", newline="") as out:
writer = csv.writer(out)
for i, row in enumerate(csv.DictReader(src)):
text = compose(row["title"], row["url"], i)
if posted_length(text) <= LIMIT:
writer.writerow([text])
else:
print("needs a shorter headline:", row["url"])csv.DictReader instead of csv.reader is not a style choice. A plain reader unpacked into for title, url in ... posts your header row as the first tweet and raises ValueError on any row with a comma in the title.
Two composition rules survive contact with the platform. Front-load the headline, because the first line is what shows in a timeline preview. And keep hashtags generic and topical. X's authenticity policy separately prohibits "using trending or popular hashtags with an intent to subvert or manipulate a conversation." Stapling a trending tag onto an unrelated link is what that looks like from the outside.
The third rule, about varying the wording, needs a section of its own further down. Rotating three templates is not enough, and the policy language says so.
Stage 3: Publish in bulk
Two routes: hand your account to a scheduler, or drive the API yourself. The choice used to be about control. Now it is mostly about money.
Option A: a scheduling tool
Schedulers hold enterprise-level API access and absorb the per-post cost, which is why the $0.20 link surcharge never reaches you. Prices below are from each vendor's own pricing page on 13 August 2026.
| Tool | Entry price | What you get |
|---|---|---|
| Buffer | Free, then $5 per channel per month | Free plan: 3 channels, 10 scheduled posts per channel. Essentials: unlimited queue |
| Hypefury | $6 a month, $5 billed yearly | Built around X specifically; recurring and evergreen posts |
| Publer | Free plan, paid tiers per account | Recurring posts on Professional, spintax recycling on Business |
| Hootsuite | $99 per user per month, billed annually | Up to 10 accounts on Standard, bulk composer included |
Two findings worth having before you sign up. Publer's pricing page states flatly that "You cannot connect Twitter / X accounts in the free version." The free tier of the tool most often recommended for bulk work excludes the network this article is about. And Hootsuite's 14-day trial imposes "daily posting limits (10-20 posts per organization, depending on plan)" with bulk scheduling switched off entirely. The trial cannot evaluate the one thing you would buy it for.
Buffer publishes the bulk-upload format, so you can generate the file directly rather than pasting. It takes a .csv saved as CSV UTF-8, with case-sensitive column headers Text, Image URL, Tags and Posting Time, times formatted YYYY-MM-DD HH:mm in 24-hour form, and no blank rows. The cap is 10 posts per channel per upload on the free plan and 100 on paid plans. A 300-post archive is three uploads on a paid plan and thirty on the free one.
Option B: the X API with Python
Drive the API yourself when the posting is one step inside a larger pipeline. You post through POST /2/tweets, authenticated with OAuth 1.0a user context or OAuth 2.0 with the tweet.write scope, and the friendliest client is still Tweepy. Version 4.17.0 went out on 2 July 2026, needs Python 3.9 or newer, and the repository is active rather than parked.
The version of this script that everyone copies is a for loop with a long sleep in it. Do not ship that. Three hundred posts at a random four-to-eight-hour interval is a process that has to stay alive for roughly 75 days, and a single crash loses its place in the file. Keep the position outside the process:
# tweepy 4.17.0, Python 3.13. Run one invocation per cron tick.
import csv, os, sqlite3, sys
import tweepy
client = tweepy.Client(
consumer_key=os.environ["X_API_KEY"],
consumer_secret=os.environ["X_API_SECRET"],
access_token=os.environ["X_ACCESS_TOKEN"],
access_token_secret=os.environ["X_ACCESS_SECRET"],
)
db = sqlite3.connect("queue.db")
db.execute("CREATE TABLE IF NOT EXISTS q (text TEXT PRIMARY KEY, posted_id TEXT)")
def load(path):
with open(path, newline="") as f:
for (text,) in csv.reader(f):
db.execute("INSERT OR IGNORE INTO q (text) VALUES (?)", (text,))
db.commit()
def post_one():
row = db.execute("SELECT text FROM q WHERE posted_id IS NULL LIMIT 1").fetchone()
if row is None:
return "queue empty"
try:
resp = client.create_tweet(text=row[0])
except tweepy.TooManyRequests as e:
return "rate limited, resets " + str(e.response.headers.get("x-rate-limit-reset"))
db.execute("UPDATE q SET posted_id = ? WHERE text = ?", (resp.data["id"], row[0]))
db.commit()
return resp.data["id"]
if __name__ == "__main__":
if len(sys.argv) > 1:
load(sys.argv[1])
print(post_one())INSERT OR IGNORE on a primary key of the post text gives you duplicate protection for free: reload the same CSV twice and nothing doubles. Tweepy 4.17.0 also added a reset_time to TooManyRequests, so you can back off on the value rather than parsing the header yourself.
What a year of this actually costs
The pay-per-use meter, read from X's pricing page on 13 August 2026:
| Operation | Price |
|---|---|
POST /2/tweets, no link |
$0.015 per request |
POST /2/tweets containing a URL |
$0.200 per request |
POST /2/tweets, summoned reply |
$0.010 per request |
| Post read | $0.005 per resource |
| Owned read, your own data | $0.001 per resource |
X's older subscription products have not all disappeared. The developer product page still advertises Free at $0 with 500 posts a month, Basic at $200 a month, and Pro at $5,000 a month with 288,000 posts. The February 2026 changelog says Basic and Pro "remain available" and that existing subscribers can opt in to pay-per-use, while describing Free-tier accounts in the past tense. Whether a new developer can still land on the free tier today is not knowable from the documentation alone, and the two pages disagree with each other.
Price out the workload the article is actually about. Three posts a day for a year is 1,095 posts, every one of them carrying a link:
| Route | Cost of 1,095 link posts a year |
|---|---|
| Buffer Essentials, one channel | $60 |
| Hypefury Flexible, billed yearly | $60 |
| X API pay-per-use | $219 |
| Hootsuite Standard, one user | $1,188 |
| X API Basic subscription | $2,400 |
The no-code route is now the cheap route by a factor of three or four. That reverses the advice in nearly every guide on this topic, this article's own earlier version included. It recommended the free API tier for a modest queue and treated the scheduler as the convenience option you pay extra for. Both halves of that are now wrong. Write the script when the posting has to sit inside a pipeline you already run, and not to save money.
Where this breaks at scale
The account cap is lower than the API cap. X's limits page states "50 original posts and 200 replies per day for unverified accounts." The same page carries an older figure of "2,400 updates per day" in its troubleshooting section, broken into semi-hourly intervals, with no account type attached to it. Assume the lower one applies to you. A 300-post archive at three a day takes a hundred days regardless.
The API caps sit above that and will never be your binding constraint. Rate limits for post creation are 100 per 15 minutes at the user level and 10,000 per 24 hours at the app level. If you are hitting those with a blog queue, the queue is the problem.
Credits run out mid-run. Pay-per-use draws down a prepaid balance. Set a spending cap, and note that auto-recharge allows at most one top-up per five-minute window, so a runaway loop stalls rather than emptying your card. A stall halfway through a batch is only recoverable if the position lives in a database, which is the whole argument for the SQLite version above.
Your own links rot inside the queue. A queue built once and drained over twelve months will eventually post URLs that a redesign turned into 404s or redirect chains. Re-check status codes before each cycle and drop anything that is not a 200. This is the failure nobody writes about, because it only appears in month four.
Titles drift. You rewrite a post, change its headline, maybe change its slug, and the queued text still carries the old one. Store the post URL as the key and regenerate the text at posting time from a fresh title lookup, rather than freezing the composed string a year in advance.
Presence is not visibility. No X documentation describes a ranking penalty for posts containing links. The recommendation code behind the For You timeline is public, and X publishes nothing alongside it that states such a rule. Every percentage in circulation traces back to a platform owner's post or a marketing blog rather than a published measurement. What X does publish is a price, and it charges 13 times more for an API post with a URL than for one without. Draw your own conclusion.
Is this still allowed on X
The mechanism is allowed. The recycling pattern most guides recommend is not, and the gap between those two sentences is where accounts get restricted.
X's automation rules explicitly permit broadcasting helpful information from outside sources, naming RSS feeds as an example. Scheduling posts in advance is ordinary use. The same page then prohibits accounts that "post duplicative or substantially similar posts on one account or over multiple accounts."
X's platform manipulation and spam policy names two behaviours that describe an evergreen link queue with uncomfortable precision. One is "repeatedly posting identical or nearly identical posts in a duplicative manner popularly known as 'Copypasta'." The other is "repeatedly posting or sending direct messages consisting of links shared without commentary." A queue of title-plus-link, cycled every few months, is literally the second one.
The developer policy closes the loop for anyone using the API: "Never post identical or substantially similar content across multiple accounts," and "Never perform bulk, aggressive, or spammy actions." Enforcement listed on the policy pages runs from account locks and reduced post reach through temporary feature restrictions to suspension.
Here is the correction. The earlier version of this article told you to rotate two or three phrasings per post and called the result a healthy queue. Read the policy language again. The bar is not "identical," it is "nearly identical" and "substantially similar." Three templates across a 300-post catalogue produce a hundred near-copies of each phrasing, which fails the test that is actually written down. Template rotation is a workaround for X's duplicate-post rejection, not compliance with its spam policy.
What stays inside the lines is narrower and slower than the usual advice:
- Write the sentence, not the template. The link is the constant. The text around it should be different prose each cycle, which caps how much of this you can automate and is the honest cost of the approach.
- Leave months between repeats, not days. A 300-post archive at two a day already fills five months without repeating anything.
- Keep link posts a minority of the feed. A timeline that is entirely outbound links is the pattern the policy describes, whatever the wording.
- One account. Pushing the same queue to a second account you own is the clearest single prohibition in the whole set, and it appears in all three documents.
- Do not automate replies at all. Since February 2026 the API will not let you, and the manual version is covered by the rule on unsolicited automated mentions.
When the queue belongs somewhere else
X is no longer the only place a link queue can live, and the economics now argue for spreading it. Bluesky charges nothing for API access and meters by points rather than dollars. Creating a record costs 3 points against a budget of 5,000 points an hour and 35,000 a day. That works out to roughly 1,666 posts an hour and 11,666 a day, two orders of magnitude above anything a blog archive needs, at zero marginal cost per link.
The same queue.csv feeds a Mastodon instance, a LinkedIn page, or your own newsletter without any of the per-post pricing. Stage one and stage two of this pipeline are platform-independent. Only stage three has a meter on it, and only on one network.
There is also the case for not doing this at all. If your archive is under fifty posts, hand-schedule them and skip the machinery. If your posts are news rather than reference material, evergreen recycling promotes content that was wrong by month three. The approach earns its keep on reference posts that stay true, which is a smaller slice of most blogs than their owners think.
Where the data work pays off
The queue is only as good as the list behind it, and "extract a clean set of URLs, titles and metadata" is the same first step behind competitor content tracking, link prospecting, and assembling a social media dataset. Archives that paginate behind JavaScript, rate-limit hard, or hide themselves behind a search box are where a managed extraction pipeline or a data-as-a-service feed does the tedious part and hands you the CSV.
Three hundred links, one at a time, each with a sentence written for it. That is the whole trick, and writing the sentence is the part no tool does for you.