Skip to content
Yoann Frayce
All systems
Nov 2025 – May 2026

ReFeed

Five and a half months on one consumer product

A mobile app that re-ranks the YouTube feed around what you actually came to do — semantic retrieval over the cards on screen, with clickbait penalised and habit rewarded.

Sole authorPrivate repositoryView architecture
Development span
5.5 months
Nov 2025 – May 2026, 32 commits
Application code
8,506 lines
TypeScript, TSX and Python, excluding dependencies
Ranking signals combined
5
semantic, popularity, freshness, habit, clickbait
Payment rails
2
Stripe webhooks and Apple in-app purchase receipt validation

Figures with a dotted underline carry their source. Hover one to see whether it was verified in code or reported in project documentation.

Context

The situation

A feed is ranked to maximise time spent. That objective is legitimate for the platform and frequently wrong for the person watching, who came to learn one specific thing and leaves forty minutes later having learned something else.

The problem

The usual responses are blunt: block the site, or install an extension that hides things. Both treat the feed as an enemy rather than as a ranked list with the wrong objective function. The interesting question is whether the same cards, reordered around a stated intent, become useful.

What I built

Do not replace the feed — re-rank it. A React Native WebView loads YouTube and injects a script that extracts the rendered cards and posts them over the bridge. The backend embeds each card through a dedicated embedding service, indexes it in Qdrant, and scores it against the session intent. A second injected script writes the new order back into the DOM.

A recommendation feed is optimised for time spent, which is not the same thing as being useful to the person watching. ReFeed does not build a new platform or block anything: it reads the cards YouTube has already rendered, embeds them, retrieves against a stated intent, and reorders the feed in place. Ranking is a weighted combination of semantic similarity, popularity on a log scale, recency and how often a card has been seen before, with a clickbait score subtracted. It was the longest-running thing I have built — five and a half months of iteration on onboarding, session flow, the paywall and the ranker — and it carries a real subscription path on both Stripe and Apple in-app purchase.

Architecture

How it fits together

  1. YouTube feedinput

    rendered in a WebView

  2. Injected scriptdeterministic

    extracts cards, posts over the RN bridge

  3. Embedding servicelanguage model

    separate HTTP boundary

  4. Qdrantdeterministic

    vector index over the cards

  5. Hybrid rankerdeterministic

    semantic + popularity + freshness + habit − clickbait

  6. User profiledeterministic

    vector centroid with recency decay

  7. Reorder scriptoutput

    writes the new order back into the DOM

Nothing is scraped from outside the app and no separate content pipeline exists: the ranker only ever sees cards YouTube has already rendered to this user, in this session.

Key decisions

The choices that shaped it

Each of these was a fork in the road where the obvious option would have produced something that looked the same and behaved worse.
  1. 01

    Re-rank the feed rather than replace it

    Building a separate content app means solving discovery, catalogue and playback from scratch, and nobody switches. Reordering the feed already on screen means the product is one WebView and two injected scripts — and it inherits everything the platform is good at while changing only the objective the cards are sorted by.

    frontend/scripts/inject.js

  2. 02

    A weighted sum, with each term doing one job

    The final score is 4× semantic similarity, plus 2× popularity on a log scale, plus 1.5× a recency bucket, plus 1× how often the card has been seen, minus 2× the clickbait score. Semantic fit dominates by design; popularity is logged so a million-view video does not simply win; and the habit term is there because a channel you return to is a weak but real signal of intent.

    backend/hybrid_ranker.py

  3. 03

    Two ways to penalise clickbait, and they behave differently

    The ranker subtracts the clickbait score. The scoring path multiplies instead — semantic × (1 − 0.4 × clickbait) — so a card with no semantic fit cannot be rescued by being honest, and a strong match is discounted proportionally rather than pushed to the bottom. The multiplicative form is the better-behaved of the two.

    backend/scoring.py

  4. 04

    Calibrate to something a person can read

    Raw scores in [0,1] mean nothing on screen. A linear calibration maps them to 0–100 with a stated slope and offset, clamped at both ends, so the number shown to the user is stable across sessions rather than relative to whatever else was in the feed that day.

    backend/calibration.py

  5. 05

    The user profile is a decayed centroid, not a history

    The profile is built from the vectors of cards already indexed, weighted down by how long ago they were last seen. It needs no separate feature store and no training step, and it forgets on its own — which for an intent-driven product is the correct default.

    backend/user_model.py

  6. 06

    Benchmark the two halves separately

    The benchmark times the query embedding and the vector search independently across repeated runs and reports percentiles. Ranking latency is felt directly — the feed visibly reorders — and knowing which half is slow is the difference between optimising the model and optimising the index.

    backend/bench.py

  7. 07

    Both payment rails, because mobile forces it

    A Stripe webhook promotes an account on subscription events, verifying the signature when the endpoint secret is configured. Apple in-app purchases are validated server-side against Apple’s production endpoint with the sandbox fallback, because a digital subscription sold inside an iOS app cannot go through Stripe.

    backend/payments.py · backend/app_store.py

Evidence

What it actually looked like

Two phone screens side by side. On the left, the session picker: five named intents — deep focus, learn one thing, motivation, chill, anti-scroll — with a duration and the session rules applied. On the right, the YouTube feed reordered in place under a small floating session pill showing the time remaining.

State an intent, and the feed is reordered in place. The interface is in French; the ranking is the same either way.

Interface

My contribution

Sole author — mobile app, injection layer, backend, ranker and both payment paths.

What I owned

  • React Native app with an injected-script bridge into a YouTube WebView
  • DOM extraction and in-place reordering of the rendered feed
  • A dedicated embedding service behind an HTTP boundary, and Qdrant indexing
  • The hybrid ranker, the clickbait scorer and the score calibration
  • User profile modelling from Qdrant vectors with recency decay
  • Benchmark tooling measuring embedding and retrieval latency separately
  • Subscription handling on both Stripe webhooks and Apple in-app purchase receipts
  • Migration of the user store and the vector store from local containers to hosted services

Stack and limits

Built with

  • React Native
  • Expo
  • TypeScript
  • WebView injection
  • Python
  • FastAPI
  • Qdrant
  • NumPy
  • Docker
  • Stripe
  • Apple StoreKit

Techniques

  • Semantic retrieval over a live DOM
  • Hybrid ranking with weighted signal combination
  • Score calibration
  • Vector-centroid user profiling with recency decay
  • Latency benchmarking split by pipeline stage

What it is not

  • No usage, revenue or user figures are claimed. The payment paths are implemented and were exercised in development; nothing in the repository evidences customers, and I am not going to imply any.
  • The ranker depends on YouTube’s rendered DOM. A markup change breaks extraction, which is the structural fragility of the whole approach.
  • The clickbait scorer is a hand-written French lexicon, not a learned classifier. It catches the obvious cases and nothing subtle.
  • The weights in the ranker were tuned by feel against my own feed. There is no held-out evaluation and no A/B comparison against the original ordering.
  • The benchmark measures latency, not ranking quality. Whether the reordering is actually better is not something the repository proves.

What carried forward

The part that transferred was not the ranker. It was learning that a scoring function nobody can read is a scoring function nobody trusts — which is why the score is calibrated to a stable 0–100 rather than shown raw, and why every later system I built makes its numbers traceable to something a person can check.