← All posts

2026-07-08

How ScrapeKey Scores and Ranks Affiliate Matches for a Keyword

Affiliate marketingRankingFastAPIMonetizationScrapeKey

ScrapeKey affiliate match program cards on mobile

Part of Building ScrapeKey: Product Engineering Notes. See also the ScrapeKey case study.

Introduction

The hook

A creator opens an affiliate network, types “coffee maker,” and gets three thousand programs — none ranked for their keyword.

Spreadsheets appear. Gut feel appears. Three weekends disappear. I’ve been there.

ScrapeKey’s promise is narrower (and harder): given keywords from a project, return a short list of programs ranked by how well they fit to monetize that phrase, with scores you can actually explain.

The objective

By the end of this post you’ll understand ScrapeKey’s matching pipeline:

  1. Load an active affiliate catalog.
  2. Score each keyword × program candidate on several dimensions.
  3. Cut low-relevance noise, keep top-N.
  4. Layer a separate monetization score (0–100) for UI badges.
  5. Persist matches and show Est. revenue signals without pretending to be Google Keyword Planner.

Prerequisites

  • Comfortable with weighted sums and 0–1 normalized scores
  • Basic CRUD with a document DB
  • Optional: prior post on plan limits (Pro unlocks deeper matching / caps)

Body

Step 1: Frame the pipeline before the math

ScrapeKey scoring pipeline diagram

[ Project keywords ]
        │
        ▼
[ Active catalog programs ]  ← policy: only matchable programs
        │
        ▼
for each keyword × program:
   relevance, commission, competition, trend, performance
        │
        ▼
 overall = Σ (component × weight)
        │
        ▼
 filter relevance ≥ threshold → sort → top N per keyword
        │
        ▼
[ MatchedAffiliate rows + monetization_score for UI ]

Product callout: Creators do not want “200 matches.” They want five they might join this week. Default max_matches_per_keyword in ScrapeKey is small (e.g. 5). Ranking quality beats dump size.


Step 2: Weighted overall score: make tradeoffs visible

AffiliateMatcher owns explicit weights (easy to tune and to defend in design review):

class AffiliateMatcher:
    def __init__(self):
        self.weight_relevance = 0.3
        self.weight_commission = 0.25
        self.weight_competition = 0.2
        self.weight_trend = 0.15
        self.weight_performance = 0.1

They sum to 1.0. That is intentional: every component is a 0–1-ish score, and overall stays comparable across programs.

Core loop (simplified from production):

for keyword in keywords:
    candidates = []
    for program in affiliate_programs:
        relevance_score = self._calculate_relevance_score(keyword, program)
        if relevance_score < 0.03:   # hard cut: noise floor
            continue
        commission_score = self._calculate_commission_score(program)
        competition_score = self._calculate_competition_score(keyword, program)
        trend_score = self._calculate_trend_score(keyword, program)
        performance_score = self._calculate_performance_score(program)
        overall_score = (
            relevance_score * self.weight_relevance
            + commission_score * self.weight_commission
            + competition_score * self.weight_competition
            + trend_score * self.weight_trend
            + performance_score * self.weight_performance
        )
        candidates.append({...})
    candidates.sort(key=lambda x: x["overall_score"], reverse=True)
    matches.extend(candidates[:max_matches_per_keyword])

Why a relevance floor? Without it, high commission alone can surface junk matches that barely share a token with the keyword. Commission is valuable after fit.


Step 3: Relevance is text fit: not magic ML (for v1)

v1 relevance in ScrapeKey is classic IR-ish work: tokenize the keyword, compare against program name, categories, description, and related fields (sequence similarity, overlapping tokens, category hits). The point of the blog-level lesson:

  • Start with explainable features.
  • Store match_data.relevance_factors for debugging and UI transparency.
  • Raise the floor threshold when the catalog grows dirty.

Product callout: When a user asks “why this program?”, you need a story better than “the model said so.” Breakdown fields earn trust in beta feedback.


Step 4: Commission as a normalized score

Programs store commission as a dict (type, value), sometimes percentage, sometimes fixed. ScrapeKey normalizes:

  • Percentagemin(value / 100, 1.0)
  • Fixed → approximate against an average order value assumption

Malformed legacy rows are read through a safe helper so one bad document doesn’t kill a match job:

def _commission_field(commission, key: str, default=0):
    if not isinstance(commission, dict):
        return default
    ...

Hiring managers care that you planned for dirty catalog data. Affiliate feeds are never clean.


Step 5: Catalog policy before brute force

Matching every keyword against every program forever does not scale. ScrapeKey loads programs through active_programs_for_matching() (policy layer) and falls back to the full collection only if needed.

Tutorial takeaway: separate “what is in the catalog” from “how we score.” Ingestion, network status, and niche coverage belong in catalog health, not inside the scorer.


Step 6: Monetization score is a second lens (0–100)

Match overall_score answers: “How good is this keyword↔program pairing?”
Monetization score answers: “How money-shaped is this keyword for a solo affiliate creator?”

They share signals but are not the same. Tools and keyword rows use:

FIT_BASE = {"high": 42, "medium": 28, "low": 12}
INTENT_BONUS = {
    "transactional": 18,
    "commercial": 14,
    "informational": 6,
    "navigational": 2,
}

def compute_monetization_score(*, affiliate_fit=None, intent=None,
                               top_match_relevance=None, top_commission_pct=None,
                               search_volume=None, difficulty=None,
                               volume_is_live=False, difficulty_is_live=False):
    ...
    # Live SEO metrics only boost when explicitly marked live

Tiers for UI badges:

ScoreTier
≥ 70high
≥ 45medium
elselow

Product callout: ScrapeKey refuses to mix estimated volume into the score as if it were live SEM rush. Estimated metrics can decorate the card; they do not quietly inflate monetization until volume_is_live / difficulty_is_live is true. That honesty is a product decision encoded in a boolean.

Serialization for the UI may rescale overall for display (e.g. 0–10 cards) while keeping raw fields for debugging, see match_serialization.py.


Step 7: Persist, notify, don’t block the human loop

After scoring:

  1. Upsert MatchedAffiliate rows (update scores if the pair already exists).
  2. Optionally notify (“Found N matches for your project”).
  3. Return cards sorted for the matches page / mobile view.

Estimated clicks/revenue helpers exist for storytelling on cards. Treat them as illustrative unless backed by real analytics, label them the way you label Est. volume.

Product callout: Matching is Pro-shaped work: unlimited projects, richer catalog, and “what do I join?” CTA. Free users get tools vocabulary; Pro users get the ranked shortlist.


Step 8: How you’d extend this without rewriting everything

Good next steps (roadmap-honest):

  • Tighten relevance with embeddings behind the same weight interface.
  • Pull live commission from network APIs into _commission_field.
  • Add per-niche catalog coverage metrics (already sketched in catalog health).
  • A/B weight sets by vertical (fitness vs SaaS).

The contract stays: components → weights → overall → top-N → UI score.

Step 9: What good “match QA” looks like

Before you trust weights in production, run a thin eval set:

KeywordExpect to seeExpect to suppress
best coffee makerskitchen / appliance programsrandom B2B SaaS
project management softwareSaaS / productivityoutdoor gear
yoga matfitness / wellnessenterprise cloud

Score a golden list of “should rank in top 5” vs “must not appear.” When you change weights, re-run the table. That is engineering maturity without a full ML platform.

Product callout: Beta testers are your first eval set. When a beta tester says “this match makes no sense,” capture the keyword + program + score breakdown. That ticket is more valuable than another UI polish pass.


Worked example (narrative)

Seed keyword: best coffee makers.

  1. Intent → commercial / transactional lean.
  2. Affiliate fit → high (buying language).
  3. Programs in “home / kitchen” categories get stronger relevance.
  4. High % commission boosts commission_score.
  5. Low relevance espresso-machine B2B SaaS programs fall under 0.03 and never appear.
  6. Top five overall_score rows surface as cards; monetization_score paints the badge.

That is the “weekend of research” compressed into one request.


Conclusion and next steps

Summary

  • Match ranking is a weighted multi-factor score, not a single vibe number — I can defend every weight in a design review.
  • A relevance floor protects the list from high-commission spam.
  • Monetization score is a second, product-facing 0–100 lens with honest Est./live rules.
  • Dirty catalogs and SSRF-free tools (prior post) matter as much as clever weights.
  • Persist top-N; optimize explainability for beta users.

Call to action

If you ship recommendations (jobs, courses, offers, programs): write down your weights. If you can’t, you don’t have a ranking system — you have a sort by created_at.

This closes the three-part set: plan limitsfree tools funnel → match scoring. See the ScrapeKey case study for the product narrative.

References

  • Classical ranking / linear feature models (IR textbooks; BM25 as contrast for pure text)
  • ScrapeKey: api/services/affiliate_matcher.py, api/services/monetization_score.py, api/services/match_serialization.py, api/services/keyword_quality.py
  • Portfolio diagrams: public/images/projects/scrapekey-diagram-scoring.png, scrapekey-diagram-funnel.png (SVG masters under docs/case_studies/extracted/assets/diagrams/)