
Key Takeaways
- RSS pubDate is an editorial timestamp, not an ingestion timestamp. Never use it as a feature cutoff without recording fetch_time.
- Clock skew between your server and the feed publisher can make future-dated entries appear in your pipeline.
- Edited posts can change pubDate, causing duplicate or out-of-order entries that break temporal splits.
- Always store both fetched_at and parsed_published_at, then validate that published_at <= fetched_at + tolerance.
- If an entry fails temporal validation, drop it or quarantine it; do not backfill it with the current time.
import feedparser
feed = feedparser.parse('https://discord.com/blog/rss.xml')
entry = feed.entries[0]
print(entry.title)
print(entry.link)
print(entry.get('published'))
print(entry.get('published_parsed'))
print(entry.get('updated'))
The output reveals that published is a raw string, while published_parsed is a struct_time. But notice there is no field for when *you* fetched the feed. That gap is where leakage enters.
With the raw structure understood, the next challenge is capturing fetch_time before any parsing happens. If you parse first and record time later, network latency and parsing overhead create a window where the clock has already moved. The fix is to record the timestamp immediately after the HTTP response is received, before feedparser touches the bytes:
import feedparser
import requests
from datetime import datetime, timezone
def fetch_feed(url):
fetched_at = datetime.now(timezone.utc)
response = requests.get(url, timeout=10)
response.raise_for_status()
feed = feedparser.parse(response.content)
return feed, fetched_at
By wrapping fetch and parse in one function, fetched_at is anchored to the moment the bytes arrived, not when the loop finished processing them.
Now that we have fetch_time captured, we can normalize each entry. feedparser gives us published_parsed as a time tuple, but RSS dates follow RFC 2822, so I use email.utils.parsedate_to_datetime to convert them into timezone-aware datetimes. Then I compare published_at against fetched_at. If published_at is more than 24 hours in the future relative to fetched_at, something is wrong—either the publisher's clock is skewed or the entry is a scheduled post that appeared early:
from email.utils import parsedate_to_datetime
def normalize_entry(entry, fetched_at):
raw_date = entry.get('published') or entry.get('updated')
if not raw_date:
return None
try:
published_at = parsedate_to_datetime(raw_date)
except (TypeError, ValueError):
return None
if published_at.tzinfo is None:
published_at = published_at.replace(tzinfo=timezone.utc)
skew = (published_at - fetched_at).total_seconds()
return {
'title': entry.title,
'link': entry.link,
'published_at': published_at.isoformat(),
'fetched_at': fetched_at.isoformat(),
'skew_seconds': skew,
}
parsedate_to_datetime handles the date formats Discord's feed emits. If the feed omits a date entirely, we skip the entry rather than inventing one.
With normalized records in hand, we need to enforce temporal purity. A simple rule: published_at must not exceed fetched_at by more than a tolerance window. I use 24 hours to account for clock drift, but anything beyond that is suspect. Entries that fail are written to a quarantine file instead of the feature store:
import json
from pathlib import Path
def write_record(record, path, tolerance_seconds=86400):
skew = record['skew_seconds']
if skew > tolerance_seconds:
record['quarantine_reason'] = 'future_published_at'
path = Path(str(path).replace('.jsonl', '_quarantined.jsonl'))
with open(path, 'a', encoding='utf-8') as f:
f.write(json.dumps(record) + '\n')
This is the production edge case my earlier post didn't cover: sometimes the source lies, and your pipeline must have a place to put the lies without corrupting the training table.
Data Source
The pipeline consumes the public Discord Engineering RSS feed at https://discord.com/blog/rss.xml. It is parsed using feedparser and fetched using requests. Date parsing relies on Python's standard email.utils.parsedate_to_datetime.
Limitations and Tradeoffs
This approach assumes the feed's clock is roughly synchronized with UTC. If a publisher consistently runs several minutes fast, your tolerance window will reject valid entries. It also cannot detect backdated posts—content published with a past pubDate but fetched now—because RSS does not provide a fetch-time from the publisher's side. For financial modeling, I would not use raw RSS pubDate as a feature at all; I would treat it as a noisy signal and derive the true availability time from my own fetch logs. If you need sub-minute latency, RSS polling is the wrong tool entirely.
FAQ
Why not just use updated instead of published?
updated changes whenever the feed is regenerated, even for minor edits. It is noisier than published and equally untrustworthy as an availability timestamp.
What if the feed returns HTTP 304 Not Modified?
requests does not send ETag or If-Modified-Since by default. You would need to implement conditional GET yourself and skip parsing when the feed has not changed, otherwise you re-fetch and re-timestamp identical content.
Can I use this for real-time trading features?
No. RSS polling is too slow and unreliable for low-latency signals. This pipeline is suitable for daily or hourly feature batches where a few minutes of latency does not matter.
How does this relate to the NEPSE pipeline?
Building on the temporal purity principles from my earlier post on leak-free feature pipelines for NEPSE prediction, this ingestion layer owns timestamp integrity before any feature ever reaches the model. The modeling layer should never have to second-guess when a signal became available.
Freshness note: Data accessed on 2026-10-22.
## Complete Scriptimport feedparser
import requests
import json
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path
FEED_URL = 'https://discord.com/blog/rss.xml'
OUTPUT_PATH = Path('discord_engineering_normalized.jsonl')
QUARANTINE_SUFFIX = '_quarantined'
TOLERANCE_SECONDS = 86400 # 24 hours
def fetch_feed(url):
fetched_at = datetime.now(timezone.utc)
try:
response = requests.get(url, timeout=15)
response.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f'Failed to fetch RSS feed: {exc}') from exc
feed = feedparser.parse(response.content)
return feed, fetched_at
def normalize_entry(entry, fetched_at):
raw_date = entry.get('published') or entry.get('updated')
if not raw_date:
return None
try:
published_at = parsedate_to_datetime(raw_date)
except (TypeError, ValueError) as exc:
return None
if published_at.tzinfo is None:
published_at = published_at.replace(tzinfo=timezone.utc)
skew_seconds = (published_at - fetched_at).total_seconds()
return {
'title': entry.get('title', ''),
'link': entry.get('link', ''),
'published_at': published_at.isoformat(),
'fetched_at': fetched_at.isoformat(),
'skew_seconds': round(skew_seconds, 2),
}
def write_record(record, path, tolerance_seconds=TOLERANCE_SECONDS):
skew = record.get('skew_seconds', 0)
target_path = path
if skew > tolerance_seconds:
record['quarantine_reason'] = 'future_published_at'
stem = path.stem
suffix = path.suffix
target_path = path.with_name(f'{stem}{QUARANTINE_SUFFIX}{suffix}')
with open(target_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(record) + '\n')
def main():
try:
feed, fetched_at = fetch_feed(FEED_URL)
except RuntimeError as exc:
print(f'[ERROR] {exc}')
return
if not feed.entries:
print('[WARNING] Feed contained no entries.')
return
clean_count = 0
quarantined_count = 0
skipped_count = 0
for entry in feed.entries:
record = normalize_entry(entry, fetched_at)
if record is None:
skipped_count += 1
continue
if record.get('quarantine_reason'):
quarantined_count += 1
else:
clean_count += 1
write_record(record, OUTPUT_PATH)
print(f'Fetched {len(feed.entries)} entries at {fetched_at.isoformat()}')
print(f'Clean: {clean_count}, Quarantined: {quarantined_count}, Skipped: {skipped_count}')
print(f'Output written to {OUTPUT_PATH}')
if __name__ == '__main__':
main()
Run it with `python script.py`. It prints a summary line you can verify: how many entries were fetched, how many passed the temporal check, and how many were quarantined. Inspect the JSONL files to see the normalized records.
If you are building features from external feeds, treat every timestamp as guilty until proven innocent. I would never join an RSS pubDate directly to market data again. Instead, I capture fetch_time at the edge, quarantine anything that arrives from the future, and treat the feed as a candidate signal rather than ground truth. For the NEPSE pipeline, this means the ingestion layer owns temporal purity, not the modeling layer.
## Next Steps
Extend this script to store fetched_at in your feature store alongside pubDate, add ETag-based conditional GET to avoid re-processing unchanged feeds, and write a unit test that injects a future-dated entry to verify your quarantine logic fires.