A Chrome extension that scrapes a thread on X (Twitter) and summarizes it into a few bullet points.
threadit/
├── extension-frontend/ Vite + React Chrome extension (Manifest V3)
└── backend-api/ FastAPI proxy that talks to OpenAI + PostgreSQL
- You open a thread on x.com and click the extension icon.
- The popup asks the content script (already injected into the page) to read the visible tweets from the DOM.
- The popup POSTs those tweets to your local FastAPI server.
- FastAPI builds a prompt, calls OpenAI's
gpt-4o-mini, and returns a bullet-point summary. - The popup displays it.
cd backend-api
# start a local Postgres instance (see "Database" section below)
docker compose up -d
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
# open .env and paste your real OpenAI key
# (DATABASE_URL already matches docker-compose.yml, no changes needed)
uvicorn main:app --reload --port 8000Check it's alive: open http://localhost:8000/health — you should see
{"status": "ok"}.
cd extension-frontend
npm install
npm run buildThis produces extension-frontend/dist/ containing manifest.json,
popup.html, the bundled React/JS assets, and content.js.
- Go to
chrome://extensions. - Turn on Developer mode (top-right toggle).
- Click Load unpacked.
- Select the
extension-frontend/distfolder. - Pin the extension icon for easy access.
- Make sure the backend from step 1 is still running.
- Open a thread on
x.com. - Click the extension icon → Summarize Thread.
If you edit the React code during development, run npm run dev for hot
reload of the popup UI in a regular browser tab (useful for styling), but
you'll still need npm run build + reload the unpacked extension to test
the real content-script/popup flow inside Chrome.
On top of the in-memory cache, every summary is persisted to a real
PostgreSQL database via SQLAlchemy's async ORM (backend-api/db.py).
This is the piece worth highlighting on a resume/report — it's not just
an LLM wrapper, it has actual relational data modeling behind it.
Schema:
threads
id, content_hash (unique), tweet_count,
created_at, last_accessed_at, access_count
│
│ 1-to-many
▼
summaries
id, thread_id (FK -> threads.id), summary_text, model, created_at
request_log (separate, denormalized — see note below)
id, content_hash, cache_hit, created_at
threads/summariesis a standard 1-to-many relationship: a thread can be re-summarized over time without losing earlier summaries.request_logdeliberately storescontent_hashdirectly instead of a foreign key. It's a write-heavy audit table used only for aggregate analytics, so skipping the join keeps writes cheap — a normalization tradeoff worth mentioning if asked about it.
Request flow for /summarize:
- Check the in-memory cache (fastest, but wiped on restart).
- Check Postgres for a previously generated summary (slower, but persistent — survives restarts, works across the whole class if you deploy one shared backend).
- Only if both miss: call OpenAI, then write the result to Postgres and warm the in-memory cache for next time.
Every call is also logged to request_log, which powers /stats.
The easiest way is Docker Compose (included):
cd backend-api
docker compose up -d # starts Postgres on localhost:5432If you don't have Docker, install PostgreSQL locally and create a
database named threadit, then set DATABASE_URL in .env
to match your credentials.
Tables are created automatically the first time the FastAPI server
starts (init_db() in db.py) — no manual migration step needed for
this project's scope.
GET /history?limit=10— recently generated summaries (a JOIN acrossthreadsandsummaries). The popup's "View History" button uses this.GET /stats— aggregate usage analytics (unique threads, total summaries, cache hit rate) computed withCOUNT()queries.GET /cache/memory— size of the fast in-memory cache (separate from the Postgres-backed history/stats).
docker exec -it threadit-db psql -U postgres -d threadit
# then, inside psql:
\dt -- list tables
SELECT * FROM threads;
SELECT * FROM summaries ORDER BY created_at DESC LIMIT 5;backend-api/cache.py implements the fast first-tier: a small in-memory
TTL cache keyed by a SHA-256 hash of the tweet content. Default TTL is 1
hour, configurable via CACHE_TTL_SECONDS in .env. It resets on
restart by design — Postgres (above) is the persistent layer.
content.js looks for article[data-testid="tweet"] and
div[data-testid="tweetText"]. These data-testid attributes are how X's
own frontend tests target these elements, so they're far more stable than
the auto-generated CSS class names — but X can still change them. If
scraping stops working, open DevTools on a tweet and check whether the
data-testid values changed, then update the selectors in
src/content/content.js.
Right now everything runs on localhost, which is fine for your own dev
machine but won't work for classmates or anyone else who installs the
extension — their browser can't reach your localhost:8000.
To make it usable for others, you need to:
Any small host works for a class project:
- Render or Railway (free/cheap tiers, easiest for FastAPI): push
this repo, set
OPENAI_API_KEYandDATABASE_URLas environment variables in their dashboard (not in a committed.envfile), and point the start command atuvicorn main:app --host 0.0.0.0 --port $PORT. Both platforms offer a managed Postgres add-on that gives you a ready-madeDATABASE_URL— use that instead of the local Docker one. - Fly.io or a small VPS (DigitalOcean, AWS EC2) if you want more
control — run the same
uvicorncommand behind a process manager (systemd, or justnohup/screenfor a demo), and either run Postgres in a container alongside it or use a managed instance (Supabase, RDS, Neon).
Once deployed, you'll have a public URL like
https://your-app.onrender.com.
In src/popup/Popup.jsx, change:
const BACKEND_URL = 'http://localhost:8000/summarize';to your deployed URL:
const BACKEND_URL = 'https://your-app.onrender.com/summarize';Then rebuild (npm run build).
In backend-api/main.py, replace the wildcard CORS origin with your
actual extension's origin so random websites can't call your API:
app.add_middleware(
CORSMiddleware,
allow_origins=["chrome-extension://<your-extension-id>"],
allow_methods=["POST", "GET"],
allow_headers=["*"],
)You'll get <your-extension-id> from chrome://extensions once loaded
(or from the Chrome Web Store listing once published).
Two options:
- Unpacked, for a small group (e.g. classmates/professor): zip the
dist/folder and share it, along with the "Load unpacked" steps above. - Chrome Web Store, for anyone: create a developer account
(one-time $5 fee), zip
dist/, and submit it through the Chrome Web Store Developer Dashboard. Review typically takes a few days. You'll need a privacy policy page since the extension reads page content (even though nothing is stored or sent anywhere except your own summarization backend).
Since your key is billed per API call, consider adding simple rate
limiting (e.g. slowapi) on the /summarize endpoint before sharing the
deployed link widely, so it can't be spammed.