6. Display and user experience
This is where the work pays off. The aggregation pipeline produces a unified List[Article] from three different APIs, and the display layer treats every article the same way. One formatter, no source-specific branching, and a CLI that ships in a few dozen lines because all the variation got handled at the boundary.
What the user actually sees
Run the aggregator from the previous page and the results render like this:
======================================================================
RESULTS
======================================================================
Query: 'python programming'
Sources: NewsAPI, Guardian, HackerNews
Articles: 12 (3 duplicates removed)
1. Python 3.13 Performance Improvements
TechCrunch | January 15, 2025 at 02:30 PM
Sarah Chen
Python's latest release brings significant speed improvements...
https://techcrunch.com/2025/01/15/python-3-13-performance
(techcrunch.com)
2. Python 3.13 is out
HackerNews | January 15, 2025 at 08:45 AM
throwaway2025
https://www.python.org/downloads/release/python-3130/
(www.python.org)
3. Python programming language gains popularity in schools
Technology | January 14, 2025 at 03:15 PM
Maya Patel
UK schools are increasingly teaching Python as first language...
https://www.theguardian.com/technology/2025/jan/14/python-schools
(www.theguardian.com)
[entries 4-10 omitted from this excerpt; the CLI renders all ten]
... and 2 more articles
======================================================================
Every article displays consistently (title, source, timestamp, author, description, URL) regardless of which API provided it. That uniform presentation is the payoff for the canonical model. You write the display code once and it works for all sources.
Why canonical models simplify display
Without normalization, the formatting code would branch on source: "if NewsAPI then show publishedAt, if Guardian then show webPublicationDate, if HackerNews then convert created_at_i." With normalization, every article has published_at in ISO format. The display code never knows or cares which API was the source.
Save this formatter at the project root as display.py:
from typing import List
from models import Article
def format_article(article: Article, index: int) -> str:
"""Format a single article for CLI display."""
lines = [f"{index}. {article.title}"]
lines.append(f" {article.source_name} | {article.format_timestamp()}")
if article.author:
lines.append(f" {article.author}")
if article.description:
desc = article.description
if len(desc) > 100:
desc = desc[:100] + "..."
lines.append(f" {desc}")
lines.append(f" {article.url}")
lines.append(f" ({article.get_domain()})")
return "\n".join(lines)
def display_results(articles: List[Article], stats: dict) -> None:
"""Render the full results view for one search."""
print("=" * 70)
print("RESULTS")
print("=" * 70)
print(f"\nQuery: '{stats['query']}'")
print(f"Sources: {', '.join(stats['sources_succeeded'])}")
duplicates = stats.get("duplicates_removed", 0)
print(f"Articles: {len(articles)} ({duplicates} duplicates removed)\n")
for i, article in enumerate(articles[:10], 1):
print(format_article(article, i))
print()
if len(articles) > 10:
print(f"... and {len(articles) - 10} more articles")
print("=" * 70)
Notice what's not in this code: a single if source == "NewsAPI" branch, a single .get("webTitle") or .get("title") fallback, a single timestamp format conversion. That's all already handled inside the normalizers; the display layer never sees it.
Wire it into a CLI
The command-line interface wraps the aggregator with interactive search and result navigation. Save this at the project root as news_aggregator.py:
from itertools import groupby
from aggregator import NewsAggregator
from display import display_results, format_article
from health_check import check_aggregator_health
def main() -> None:
aggregator = NewsAggregator()
last_results = []
last_stats = {}
print("=" * 70)
print("NEWS AGGREGATOR")
print("=" * 70)
print("\nSearch news from NewsAPI, The Guardian, and HackerNews\n")
print("Commands:")
print(" search - search for news articles")
print(" again - re-display the last search results")
print(" group - show last results grouped by source")
print(" help - show this help message")
print(" quit - exit application")
print("=" * 70)
while True:
try:
command = input("\naggregator> ").strip()
except (KeyboardInterrupt, EOFError):
print()
break
if not command or command == "quit":
break
if command == "search":
print("Usage: search ")
continue
if command.startswith("search "):
query = command[len("search "):].strip()
if not query:
print("Usage: search ")
continue
print(f"\nSearching for '{query}'...\n")
last_results, last_stats = aggregator.search(query, max_per_source=5)
warnings = check_aggregator_health(last_stats)
if warnings:
print("\nHEALTH WARNINGS:")
for warning in warnings:
print(f" {warning}")
display_results(last_results, last_stats)
elif command == "help":
print("Commands: search , again, group, help, quit")
elif command == "again":
if last_results:
display_results(last_results, last_stats)
else:
print("No previous search results. Try: search ")
elif command == "group":
if not last_results:
print("No previous search results. Try: search ")
continue
grouped = sorted(last_results, key=lambda a: a.source_name)
for source_name, group in groupby(grouped, key=lambda a: a.source_name):
articles_for_source = list(group)
print(f"\n--- {source_name} ({len(articles_for_source)}) ---")
for i, article in enumerate(articles_for_source, 1):
print(format_article(article, i))
print()
else:
print(f"Unknown command: {command}. Type 'help' for options.")
if __name__ == "__main__":
main()
Four user-facing features fall out of the canonical model:
- Interactive search: natural commands (
search,again,group,help,quit) - Multiple views: chronological listing or grouped by source, both using the same article objects with no source-specific code
- Source transparency: clear indication of which APIs contributed and which failed (driven by the
statsdict) - Stateful session: remember the last search so follow-up commands can re-display or regroup without re-fetching
With the display layer in place, the next page asks the harder question: what still goes wrong despite all this defensive programming, and why isn't it enough?