2026-07-08
How ScrapeKey Scores and Ranks Affiliate Matches for a Keyword

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.
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 explain.
The objective
By the end of this post you’ll understand ScrapeKey’s matching pipeline:
- Load an active affiliate catalog.
- Score each keyword × program candidate on several dimensions.
- Cut low-relevance noise, keep top-N.
- Layer a separate monetization score (0–100) for UI badges.
- 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

[ 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_factorsfor 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:
- Percentage →
min(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:
| Score | Tier |
|---|---|
| ≥ 70 | high |
| ≥ 45 | medium |
| else | low |
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:
- Upsert
MatchedAffiliaterows (update scores if the pair already exists). - Optionally notify (“Found N matches for your project”).
- 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:
| Keyword | Expect to see | Expect to suppress |
|---|---|---|
best coffee makers | kitchen / appliance programs | random B2B SaaS |
project management software | SaaS / productivity | outdoor gear |
yoga mat | fitness / wellness | enterprise 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.
- Intent → commercial / transactional lean.
- Affiliate fit → high (buying language).
- Programs in “home / kitchen” categories get stronger relevance.
- High % commission boosts commission_score.
- Low relevance espresso-machine B2B SaaS programs fall under 0.03 and never appear.
- 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.
- 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 limits → free 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 underdocs/case_studies/extracted/assets/diagrams/)