Skip to content

Is Your RSS Feed Lying to You? Temporal Purity When Ingesting Discord Engineering's RSS

Is Your RSS Feed Lying to You? Temporal Purity When Ingesting Discord Engineering's RSS
I learned this the hard way while extending the NEPSE feature pipeline from my previous post. That pipeline guarded against macro-indicator leakage by aligning every indicator to its official release date. Then I decided to enrich it with external engineering signals—things like platform outages, API changes, and infrastructure posts from Discord Engineering—thinking they might correlate with sentiment around tech-adjacent stocks. I parsed their RSS feed with feedparser, stored each entry's pubDate, and joined it to daily price data. The backtests were spectacular. They were also wrong. The leak was in the timestamp. Discord's RSS feed publishes entries with pubDate values like "Wed, 21 Oct 2026 14:30:00 GMT", but that is an editorial timestamp, not an ingestion timestamp. My cron job ran at 02:00 UTC and happily consumed an article published at 14:00 UTC the previous afternoon. To the model, that article existed at 14:00, but in reality, no one could have used it as a feature until the crawler fetched it at 02:00 the next morning. Worse, when an editor fixes a typo, some feed generators bump pubDate, making old content look new. If you sort by pubDate and take the latest entries, you are sorting by a clock you do not control. This post is for readers who have already worked through my earlier leak-free pipeline article and now need to ingest third-party feeds without reintroducing leakage. You will build a small ingestion pipeline that fetches Discord Engineering's RSS, captures the exact fetch timestamp, normalizes pubDate against fetch_time, and quarantines entries that violate temporal sanity. By the end, you will know when to trust an RSS date and when to discard it.

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.
## Temporal Purity Guards for RSS Ingestion First, inspect what the feed actually returns. feedparser parses the RSS XML and exposes each entry's metadata, but the critical distinction is between the raw publish string and the moment your code received the bytes. Here is what a single entry looks like when you print its parsed fields:
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 Script
import 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.

إرسال تعليق

Hi! How can we help you? Send us a message and we'll get back to you.