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.
Paste a blog URL into the page, click Generate Summary, and the backend:
- Fetches the page and extracts the article body.
- Sends it to Gemini with a prompt stating the 300-word / 20-sentence limits.
- 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.
- Returns the summary plus stats (article word count, summary word count, summary sentence count, processing time) as JSON.
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 — servefrontend/index.html,style.css,script.jsvia FastAPI'sStaticFiles.
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
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)Run the backend — it serves the frontend too, so this is the only command you need:
cd backend
uvicorn main:app --reload --port 8000Then open http://localhost:8000 in a browser. The interactive API docs (auto-generated by FastAPI) are at http://localhost:8000/docs.
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.
Returns {"status": "ok"}. Used as Render's health check.
This repo includes a render.yaml, so Render can deploy it from a Blueprint
without manual service configuration:
- Push this repo to GitHub.
- In the Render dashboard: New + → Blueprint → select the repo.
- Render reads
render.yamland 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
- Build command:
- Set the
GEMINI_API_KEYenvironment variable in the Render dashboard (it's markedsync: falseinrender.yaml, so Render will prompt you for it rather than expecting it in the repo). - 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.
https://<your-app-name>.onrender.com — add your URL here after deploying.
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.
| 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 |
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/.
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:
- 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.
- 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. - 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.
- Persistent storage.
backend/output/summary.txtis 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_htmlandsummarize_with_geminiuse blockingrequestscalls inside a sync FastAPI route. Fine at this scale; under real concurrent load, switching tohttpx.AsyncClientandasync defroutes 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.