Skip to content

About

AI-powered blog summarizer that extracts web articles and generates concise summaries using Gemini, FastAPI, and a responsive web interface.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Blog Article Summarizer

A small web app that fetches a blog post from a URL and returns a summary that is guaranteed to be no more than 300 words and no more than 20 sentences, using the Gemini API. FastAPI backend, vanilla HTML/CSS/JS frontend, deployable as a single Render web service.

This started as a CLI script (python main.py <url>) and was restructured into a web app. The summarization pipeline itself — fetch, extract, summarize, validate/trim — is unchanged from the original; only the orchestration layer around it changed from a terminal script to an HTTP API.

Project Overview

Paste a blog URL into the page, click Generate Summary, and the backend:

  1. Fetches the page and extracts the article body.
  2. Sends it to Gemini with a prompt stating the 300-word / 20-sentence limits.
  3. Independently re-checks the model's output against those limits and trims at a sentence boundary if it overshot — the contract is enforced in code, not just requested in the prompt.
  4. Returns the summary plus stats (article word count, summary word count, summary sentence count, processing time) as JSON.

Architecture

Browser  ──POST /summarize──▶  FastAPI (backend/main.py)
   ▲                                 │
   │                                 ├─▶ requests + BeautifulSoup   (fetch + extract)
   │                                 ├─▶ Gemini API                 (summarize)
   │                                 └─▶ enforce_limits()           (validate/trim)
   └────────static files─────────────── frontend/ (StaticFiles mount)

One process serves everything:

  • POST /summarize — runs the pipeline, returns JSON.
  • GET /health — liveness check (used by Render).
  • GET / and other static paths — serve frontend/index.html, style.css, script.js via FastAPI's StaticFiles.

Why one service instead of a separate frontend host + backend host? Two services would mean CORS configuration, an extra env var so the frontend knows the backend's URL, and two things to deploy and keep in sync. Serving the frontend from the same FastAPI process means same-origin requests (no CORS needed in production), one render.yaml service, and one URL. CORS middleware is still included in the backend so the frontend can be pointed at a different host during local development if you want to run it that way.

blog_summarizer/
├── backend/
│   ├── main.py            # pipeline + FastAPI app + /summarize endpoint
│   ├── requirements.txt
│   ├── .env.example
│   └── output/             # most recent summary.txt lands here
├── frontend/
│   ├── index.html
│   ├── style.css
│   └── script.js
├── render.yaml
└── README.md

Setup

git clone <your-repo-url>
cd blog_summarizer/backend
pip install -r requirements.txt
cp .env.example .env
# edit .env and add your Gemini API key (free tier: https://aistudio.google.com/apikey)

Local Development

Run the backend — it serves the frontend too, so this is the only command you need:

cd backend
uvicorn main:app --reload --port 8000

Then open http://localhost:8000 in a browser. The interactive API docs (auto-generated by FastAPI) are at http://localhost:8000/docs.

API Documentation

POST /summarize

Request body

{ "url": "https://example.com/blog/post" }

Response — 200 OK

{
  "summary": "...",
  "article_word_count": 3500,
  "summary_word_count": 280,
  "summary_sentence_count": 15,
  "processing_time": 3.2
}

Error responses

Status When Body
400 Invalid URL, page has no extractable article, fetch timeout/network failure, or Gemini call/response failure {"detail": "<specific message>"}
422 Request body is missing url or isn't valid JSON FastAPI's standard validation error body
500 GEMINI_API_KEY isn't configured on the server {"detail": "GEMINI_API_KEY is not configured on the server."}

These all come from the same SummarizerError exception type used internally — see "Edge cases handled" below for the full list of failure modes it covers.

GET /health

Returns {"status": "ok"}. Used as Render's health check.

Deployment Guide (Render)

This repo includes a render.yaml, so Render can deploy it from a Blueprint without manual service configuration:

  1. Push this repo to GitHub.
  2. In the Render dashboard: New + → Blueprint → select the repo.
  3. Render reads render.yaml and creates one web service:
    • Build command: pip install -r backend/requirements.txt
    • Start command: cd backend && uvicorn main:app --host 0.0.0.0 --port $PORT
  4. Set the GEMINI_API_KEY environment variable in the Render dashboard (it's marked sync: false in render.yaml, so Render will prompt you for it rather than expecting it in the repo).
  5. Deploy. Render assigns a URL like https://blog-summarizer.onrender.com.

If you'd rather configure it by hand instead of using the Blueprint: create a new Web Service, point it at this repo, and set the build/start commands and environment variables exactly as listed above.

Note on the free plan: Render's free web services spin down after periods of inactivity, so the first request after idle time can take ~30-60 seconds while the instance wakes up. This is a Render platform behavior, not something the app needs to handle.

Live Demo

https://<your-app-name>.onrender.com — add your URL here after deploying.

Design decisions

Why trust-but-verify the word/sentence limits, instead of just asking the model nicely? LLMs are unreliable at obeying hard numeric constraints. The prompt asks for 300 words / 20 sentences, but the code never assumes the model got it right. enforce_limits() independently counts words and sentences and trims if needed. This is the single most important design decision in the project — the difference between "the model usually follows the rule" and "the program always follows the rule" — and it carries over unchanged from the CLI version into the API response.

Why a regex sentence splitter instead of nltk/spacy? A sentence boundary in English prose is reliably marked by ., !, or ? followed by whitespace and a capital letter or digit. A one-line regex (split_into_sentences) handles this correctly for the clean, well-formed prose an LLM produces, without a multi-hundred-MB NLP model or a non-deterministic tokenizer. Trade-off: not robust against deliberately adversarial text (e.g. "Dr. Smith went to Washington D.C. today.") — fine for this task, and swappable later behind the same function signature.

Why requests + BeautifulSoup instead of a readability library? The extraction heuristic — prefer <article>/<main>, otherwise fall back to all <p> tags — covers the large majority of blog platforms in about 15 lines a reviewer can read in one glance, without the extra dependency weight of readability-lxml or newspaper3k.

Why a raw REST call to Gemini instead of the google-generativeai SDK? One fewer dependency, and the HTTP request/response shape stays fully visible in the code instead of hidden behind an SDK. The model name is read from GEMINI_MODEL (default gemini-2.5-flash) so it can be swapped without a code change.

Why is the pipeline still all in one backend/main.py instead of split into a separate module? To match the requested project structure and keep the diff from the original CLI version easy to follow — every pipeline function is the same function, in the same order, just with the CLI-only main()/sys.argv code at the bottom replaced by a FastAPI route. Splitting the pipeline into its own module (e.g. summarizer.py) would be a reasonable next step for a larger project, but isn't necessary at this size.

Why a compression bar instead of just the four stat numbers? The four stats already say what happened; the bar shows it. Article and summary word counts both render as relative bar widths, making the size of the actual reduction immediately visible rather than something the reader has to do mental math on.

Edge cases handled

Case Handling
Invalid URL validate_url() checks scheme/netloc before any network call → 400
Missing API key Checked at request time, before fetching anything → 500
Request timeout requests timeout + caught Timeout exception → 400
Network/DNS failure Caught ConnectionError / RequestException → 400
Empty or non-article page Minimum character-count check after extraction → 400
Unexpected HTML structure <article>/<main> → all-<p> fallback chain
Failed/blocked Gemini response Checks HTTP status, response JSON shape, and empty text → 400
Summary too long enforce_limits() trims at sentence boundaries (silent, not an error)
Missing/malformed request body FastAPI/Pydantic validation → 422 automatically

Testing performed

The pure logic functions (URL validation, <article>/<main>/fallback extraction, empty-page rejection, sentence splitting, limit enforcement) were unit-tested offline with synthetic HTML and text. At the API layer, every endpoint was exercised with FastAPI's TestClient: static file serving, /health, 422 on a missing url field, 400 on an invalid URL and on an unreachable host, 500 when GEMINI_API_KEY is unset, and a full happy path with fetch_html/summarize_with_gemini mocked, confirming the response JSON shape matches the spec exactly and that summary.txt is written to backend/output/.

Bonus: scaling to blogs longer than 10,000 words

The current design sends the whole extracted article to Gemini in one prompt, which is fine for a typical blog post but will eventually hit a model's context window or hurt quality on very long input. Three complementary changes would make it scale:

  1. Chunking. Split the article into overlapping chunks (e.g. ~2,000 words each, ~100-word overlap) at paragraph boundaries rather than arbitrary character counts, so no chunk ends mid-thought.
  2. Map-reduce summarization. Map: summarize each chunk independently (capped at ~150 words each). Reduce: concatenate the chunk summaries and run them through the existing summarize_with_gemini() + enforce_limits() unchanged, since the "article" at that point is just shorter.
  3. Hierarchical summarization. For extremely long documents, do the reduction in layers — summarize chunks → summarize groups of those summaries → repeat until the combined text fits in one prompt, then run the final pass. Same idea as a merge-sort: reduce in stages, not all at once.

enforce_limits() stays exactly as-is regardless of how many layers of reduction happened before it — it only ever has to trim one final text to 300 words / 20 sentences.

Future Improvements

  • Persistent storage. backend/output/summary.txt is overwritten on every request and lives on Render's ephemeral filesystem (wiped on redeploy). A real "history" feature would need a database or object storage.
  • Async I/O. fetch_html and summarize_with_gemini use blocking requests calls inside a sync FastAPI route. Fine at this scale; under real concurrent load, switching to httpx.AsyncClient and async def routes would stop one slow request from blocking others.
  • Streaming responses. For long articles, streaming the Gemini response back to the frontend token-by-token would make the UI feel faster than waiting for the full JSON response.
  • Chunking for long articles, as described in the bonus section above.
  • Rate limiting, since this is a public endpoint that calls a paid API per request.

About

AI-powered blog summarizer that extracts web articles and generates concise summaries using Gemini, FastAPI, and a responsive web interface.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages