feat(affiliate): 启用 4 家推广链接,支持「保留套餐页 + 追加参数」

配置(letdeals/affiliate.py DEFAULTS)
- racknerd.com      整体替换(入口在 my.racknerd.com 独立子域)
- cloudcone.com     追加 ref=14691
- greencloudvps.com 追加 aff=10361
- deluxhost.net     追加 ref=M7U4WHX5

两种模式按厂商给的链接形态选择:
- 独立入口(my.racknerd.com/aff.php)-> 只能整体替换
- 原站 + 查询参数(?ref= / ?aff=)  -> 追加参数

为什么优先追加参数:整体替换会把「具体套餐页」降级成首页,
用户要多点几步,降低转化率。实测 DeluxHost 原本直达
/en/cart?add=storage-1,替换后变成首页。

追加参数的安全性:同名参数已存在时替换而非重复追加
(避免 ?ref=old&ref=new 让厂商解析歧义)。

测试:231 通过。fixture 里显式清空 DEFAULTS ——
项目已填入真实链接,但测试需从「未配置」基线开始。
This commit is contained in:
gungun88 committed 2026-10-18 01:59:06 +08:00
1 parent 59ac735751
commit 2b137b3b39
2 files changed
+221 -133

No files matched your search

+109 -49
View File
@@ -2,29 +2,43 @@
用途
----
把厂商域名映射到**你自己的推广链接**。用户通过该链接下单,你拿佣金。
在不牺牲「直达具体套餐页」的前提下带上推广参数,用户下单你拿佣金。
设计原则
--------
1. **默认空表**:不配置就完全不影响现有行为(用原始链接)。
2. **域名级映射**:按厂商域名匹配,与帖子内容无关,规则简单可预测。
3. **不猜参数**:每家的 AFF 参数格式不同,必须显式给出完整链接,
不做 `?aff=` 这类拼接猜测 —— 拼错会导致佣金丢失且难以发现。
4. **可灰度**:填一家生效一家,其余继续走原链接。
两种模式(按厂商配置自动选择)
------------------------------
**A. 参数追加(推荐)** —— 保留原帖的具体套餐/加购页,把推广参数拼上去:
https://deluxhost.net/en/cart?add=storage-1&cycle=monthly
-> https://deluxhost.net/en/cart?add=storage-1&cycle=monthly&ref=M7U4WHX5
好处:用户一步到套餐页,转化率最高;佣金凭参数归属。
**B. 全量替换(兜底)** —— 厂商只支持专属入口时,只能跳到该入口:
https://racknerd.com/ctrl-control-panel
-> https://my.racknerd.com/aff.php?aff=21559
代价:用户落在首页,要自己找套餐。
配置方式
--------
环境变量 ``LDR_AFFILIATES`` 用 JSON 给出,或直接改下方 ``DEFAULTS``:
环境变量 ``LDR_AFFILIATES``(JSON),值是:
LDR_AFFILIATES='{"racknerd.com": "https://my.racknerd.com/aff.php?aff=123"}'
- **字符串**:完整推广链接 -> 走 B(全量替换)
- **对象**:``{"param": "ref", "value": "M7U4WHX5"}`` -> 走 A(追加参数)
可选 ``"landing"`` 指定该厂商的专属落地页
键是**域名**(可带或不带 www),值是**完整的推广链接**。
例:
合规提醒
LDR_AFFILIATES='{
"deluxhost.net": {"param": "ref", "value": "M7U4WHX5"},
"racknerd.com": "https://my.racknerd.com/aff.php?aff=21559"
}'
安全默认
--------
多数厂商禁止:自己下单赚自己佣金、用脚本批量注册、投竞品品牌词广告。
启用前请确认各家条款。另外建议在站点上**明示**「部分链接含推广佣金」——
佣金由商家从利润中出,不影响用户支付价格。
不配置就**完全不影响现有行为**(用原始链接)。
JSON 写错时静默回退空表 —— 最坏只是没有推广链接,不会中断抓取。
"""
from __future__ import annotations
@@ -32,17 +46,31 @@ from __future__ import annotations
import json
import os
import re
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
# 域名 -> 完整推广链接。
# 例:'racknerd.com': 'https://my.racknerd.com/aff.php?aff=123'
DEFAULTS: dict[str, str] = {}
# 域名 -> 配置。字符串 = 完整链接(整体替换);dict = 追加参数。
#
# 模式选择依据:**看厂商给的链接是什么形式**
# - 独立入口(如 my.racknerd.com/aff.php?aff=)-> 只能整体替换,
# 因为原站路径不接受该参数。
# - 原站 + 查询参数(如 deluxhost.net/?ref=)-> 可追加参数,
# 从而保留「具体套餐页」这一层,转化率更高。
DEFAULTS: dict[str, object] = {
# RackNerd:推广入口在独立子域 my.racknerd.com,只能整体替换
"racknerd.com": "https://my.racknerd.com/aff.php?aff=21559",
# CloudCone:ref 参数作用于原站
"cloudcone.com": {"param": "ref", "value": "14691"},
# GreenCloud:billing 路径下的 aff 参数
"greencloudvps.com": {"param": "aff", "value": "10361"},
# DeluxHost:ref 参数作用于原站
"deluxhost.net": {"param": "ref", "value": "M7U4WHX5"},
}
def _load() -> dict[str, str]:
def _load() -> dict[str, object]:
"""读取映射:环境变量优先,其次模块内 DEFAULTS。
环境变量解析失败时**静默回退到 DEFAULTS** —— 配置写错不应导致
抓取流程失败,最坏情况只是没有推广链接。
解析失败时静默回退 —— 配置写错不应导致抓取流程失败。
"""
raw = os.getenv("LDR_AFFILIATES", "").strip()
if not raw:
@@ -53,27 +81,62 @@ def _load() -> dict[str, str]:
return dict(DEFAULTS)
if not isinstance(data, dict):
return dict(DEFAULTS)
out = dict(DEFAULTS)
for k, v in data.items():
if isinstance(k, str) and isinstance(v, str) and v.startswith("http"):
out[k.strip().lower()] = v.strip()
if not isinstance(k, str) or not k.strip():
continue
key = k.strip().lower()
if isinstance(v, str) and v.startswith("http"):
out[key] = v.strip()
elif isinstance(v, dict) and v.get("param") and v.get("value"):
item = {"param": str(v["param"]).strip(),
"value": str(v["value"]).strip()}
landing = v.get("landing")
if isinstance(landing, str) and landing.startswith("http"):
item["landing"] = landing.strip()
out[key] = item
return out
def _domain_of(url: str) -> str:
"""从 URL 取可注册域(www. 视为同一家)。"""
m = re.match(r"https?://(?:www\.)?([a-z0-9][a-z0-9\-\.]*)", url or "",
re.I)
m = re.match(r"https?://(?:www\.)?([a-z0-9][a-z0-9\-\.]*)", url or "", re.I)
return m.group(1).lower().rstrip(".") if m else ""
def rewrite(vendor_url: str | None,
mapping: dict[str, str] | None = None) -> str | None:
"""如果该厂商配置了推广链接,替换之;否则原样返回。
def _match(url: str, table: dict[str, object]):
"""按 精确域名 -> 去子域 的顺序找配置,返回 (配置, 命中的域名)。"""
dom = _domain_of(url)
if not dom:
return None, None
if dom in table:
return table[dom], dom
parts = dom.split(".")
for i in range(1, len(parts) - 1):
cand = ".".join(parts[i:])
if cand in table:
return table[cand], cand
return None, None
匹配顺序:
1) 完整域名精确匹配(a.b.com)
2) 去掉子域后匹配(b.com)—— 兼容 my.xxx.com、billing.xxx.com
def append_param(url: str, param: str, value: str) -> str:
"""在 URL 上追加查询参数(保留原有参数与路径)。
若该参数已存在,**替换其值**而不是重复追加 —— 避免出现
``?ref=old&ref=new`` 这类让厂商解析歧义的链接。
"""
parts = urlsplit(url)
q = dict(parse_qsl(parts.query, keep_blank_values=True))
q[param] = value
return urlunsplit((parts.scheme, parts.netloc, parts.path,
urlencode(q), parts.fragment))
def rewrite(vendor_url: str | None,
mapping: dict[str, object] | None = None) -> str | None:
"""把厂商链接改写为带推广参数的版本;未配置则原样返回。
优先「保留具体页面 + 追加参数」,厂商只提供专属入口时才整体替换。
"""
if not vendor_url:
return vendor_url
@@ -81,28 +144,25 @@ def rewrite(vendor_url: str | None,
if not table:
return vendor_url
dom = _domain_of(vendor_url)
if not dom:
cfg, _hit = _match(vendor_url, table)
if cfg is None:
return vendor_url
# 精确匹配
if dom in table:
return table[dom]
# 完整链接模式:整体替换
if isinstance(cfg, str):
return cfg
# 去掉子域:a.b.com -> b.com
parts = dom.split(".")
for i in range(1, len(parts) - 1):
cand = ".".join(parts[i:])
if cand in table:
return table[cand]
return vendor_url
# 参数追加模式:保留路径,仅加参数
return append_param(vendor_url, cfg["param"], cfg["value"])
def summary(mapping: dict[str, str] | None = None) -> str:
"""返回一行人类可读的配置摘要,便于启动日志确认。"""
def summary(mapping: dict[str, object] | None = None) -> str:
"""一行人类可读摘要,便于启动日志确认。"""
table = _load() if mapping is None else mapping
if not table:
return "未配置推广链接(使用原始链接)"
return "已配置 %d 家厂商的推广链接:%s" % (
len(table), "、".join(sorted(table)))
parts = []
for k in sorted(table):
parts.append("%s(%s)" % (
k, "追加参数" if isinstance(table[k], dict) else "整体替换"))
return "已配置 %d 家:%s" % (len(table), "、".join(parts))
+112 -84
View File
@@ -1,6 +1,10 @@
"""联盟推广链接映射测试。
核心诉求:**默认不配置时完全不改变现有行为**,配置后按域名精确替换。
两种模式:
A. 参数追加(保留具体套餐页,转化率最高)
B. 整体替换(厂商只给专属入口时)
核心诉求:**默认不配置时完全不改变现有行为**。
"""
from __future__ import annotations
@@ -14,19 +18,27 @@ from letdeals import affiliate
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
"""每个用例前清空环境变量,避免互相污染。"""
"""清空环境变量,并清空模块内 DEFAULTS。
项目里 DEFAULTS 已填入真实推广链接(线上在用),
但测试需要从「未配置」这个基线开始,因此这里显式清空。
"""
monkeypatch.delenv("LDR_AFFILIATES", raising=False)
monkeypatch.setattr(affiliate, "DEFAULTS", {})
yield
def _set(monkeypatch, mapping):
monkeypatch.setenv("LDR_AFFILIATES", json.dumps(mapping))
# --------------------------------------------------------------------------
# 默认行为:不配置就等于不启用
# 默认行为
# --------------------------------------------------------------------------
def test_default_is_empty_and_passthrough():
def test_default_empty_passthrough():
assert affiliate._load() == {}
assert affiliate.rewrite("https://example.com/deals") == \
"https://example.com/deals"
assert affiliate.rewrite("https://a.com/deals") == "https://a.com/deals"
def test_summary_when_empty():
@@ -34,137 +46,153 @@ def test_summary_when_empty():
def test_rewrite_none_returns_none():
"""没有厂商链接时不能崩,也不能凭空造一个。"""
assert affiliate.rewrite(None) is None
assert affiliate.rewrite("") == ""
def test_rewrite_unmapped_domain_passthrough(monkeypatch):
monkeypatch.setenv("LDR_AFFILIATES",
json.dumps({"a.com": "https://aff.test/?id=1"}))
assert affiliate.rewrite("https://b.com/deals") == "https://b.com/deals"
def test_unmapped_passthrough(monkeypatch):
_set(monkeypatch, {"a.com": {"param": "ref", "value": "1"}})
assert affiliate.rewrite("https://b.com/x") == "https://b.com/x"
# --------------------------------------------------------------------------
# 命中替换
# 模式 A:追加参数
# --------------------------------------------------------------------------
def test_rewrite_exact_domain(monkeypatch):
monkeypatch.setenv("LDR_AFFILIATES",
json.dumps({"a.com": "https://aff.test/?id=1"}))
assert affiliate.rewrite("https://a.com/deals") == "https://aff.test/?id=1"
def test_append_param_keeps_path_and_query(monkeypatch):
"""关键:保留具体套餐页,只追加推广参数。"""
_set(monkeypatch, {"deluxhost.net": {"param": "ref", "value": "ABC"}})
out = affiliate.rewrite(
"https://deluxhost.net/en/cart?add=storage-1&cycle=monthly")
assert "add=storage-1" in out
assert "cycle=monthly" in out
assert "ref=ABC" in out
assert out.startswith("https://deluxhost.net/en/cart?")
def test_rewrite_ignores_www(monkeypatch):
monkeypatch.setenv("LDR_AFFILIATES",
json.dumps({"a.com": "https://aff.test/?id=1"}))
assert affiliate.rewrite("https://www.a.com/deals") == \
"https://aff.test/?id=1"
def test_rewrite_matches_parent_domain_of_subdomain(monkeypatch):
"""my./billing. 这类购买子域也应命中厂商规则。"""
monkeypatch.setenv("LDR_AFFILIATES",
json.dumps({"a.com": "https://aff.test/?id=1"}))
assert affiliate.rewrite("https://my.a.com/store/vps") == \
"https://aff.test/?id=1"
assert affiliate.rewrite("https://billing.a.com/cart") == \
"https://aff.test/?id=1"
def test_rewrite_subdomain_specific_rule_wins(monkeypatch):
"""同时配了子域和父域时,更具体的子域规则优先。"""
monkeypatch.setenv("LDR_AFFILIATES", json.dumps({
"a.com": "https://aff.test/generic",
"my.a.com": "https://aff.test/specific",
}))
assert affiliate.rewrite("https://my.a.com/store") == \
"https://aff.test/specific"
def test_append_param_to_url_without_query(monkeypatch):
_set(monkeypatch, {"a.com": {"param": "ref", "value": "9"}})
assert affiliate.rewrite("https://a.com/deals") == \
"https://aff.test/generic"
"https://a.com/deals?ref=9"
def test_append_param_replaces_existing(monkeypatch):
"""已有同名参数时替换,不重复追加(避免厂商解析歧义)。"""
_set(monkeypatch, {"a.com": {"param": "ref", "value": "NEW"}})
out = affiliate.rewrite("https://a.com/x?ref=OLD&y=1")
assert out.count("ref=") == 1
assert "ref=NEW" in out
assert "y=1" in out
def test_append_param_preserves_fragment(monkeypatch):
_set(monkeypatch, {"a.com": {"param": "ref", "value": "1"}})
out = affiliate.rewrite("https://a.com/deals#plans")
assert "#plans" in out
assert "ref=1" in out
# --------------------------------------------------------------------------
# 模式 B:整体替换
# --------------------------------------------------------------------------
def test_full_url_mode(monkeypatch):
_set(monkeypatch, {"racknerd.com": "https://my.racknerd.com/aff.php?aff=1"})
assert affiliate.rewrite("https://racknerd.com/BlackFriday/") == \
"https://my.racknerd.com/aff.php?aff=1"
# --------------------------------------------------------------------------
# 域名匹配
# --------------------------------------------------------------------------
def test_www_ignored(monkeypatch):
_set(monkeypatch, {"a.com": {"param": "ref", "value": "1"}})
assert "ref=1" in affiliate.rewrite("https://www.a.com/deals")
def test_subdomain_matches_parent(monkeypatch):
_set(monkeypatch, {"a.com": {"param": "ref", "value": "1"}})
assert "ref=1" in affiliate.rewrite("https://my.a.com/store")
assert "ref=1" in affiliate.rewrite("https://billing.a.com/cart")
def test_specific_subdomain_wins(monkeypatch):
_set(monkeypatch, {
"a.com": {"param": "ref", "value": "generic"},
"my.a.com": {"param": "ref", "value": "specific"},
})
assert "specific" in affiliate.rewrite("https://my.a.com/x")
assert "generic" in affiliate.rewrite("https://a.com/x")
# --------------------------------------------------------------------------
# 配置容错
# --------------------------------------------------------------------------
def test_bad_json_falls_back_to_default(monkeypatch):
"""配置写错不能让抓取挂掉 —— 最坏只是没有推广链接。"""
monkeypatch.setenv("LDR_AFFILIATES", "{ not json")
def test_bad_json_falls_back(monkeypatch):
monkeypatch.setenv("LDR_AFFILIATES", "{ bad")
assert affiliate._load() == {}
assert affiliate.rewrite("https://a.com/x") == "https://a.com/x"
def test_non_dict_json_ignored(monkeypatch):
monkeypatch.setenv("LDR_AFFILIATES", '["https://a.com"]')
assert affiliate._load() == {}
def test_invalid_values_filtered(monkeypatch):
"""只接受以 http 开头的字符串值。"""
monkeypatch.setenv("LDR_AFFILIATES", json.dumps({
"good.com": "https://aff.test/?id=1",
def test_invalid_entries_filtered(monkeypatch):
_set(monkeypatch, {
"ok.com": {"param": "ref", "value": "1"},
"str.com": "https://aff.test/x",
"bad.com": "not-a-url",
"incomplete.com": {"param": "ref"},
"num.com": 123,
}))
table = affiliate._load()
assert "good.com" in table
assert "bad.com" not in table
assert "num.com" not in table
})
t = affiliate._load()
assert "ok.com" in t and "str.com" in t
assert "bad.com" not in t
assert "incomplete.com" not in t
assert "num.com" not in t
def test_keys_are_lowercased(monkeypatch):
monkeypatch.setenv("LDR_AFFILIATES",
json.dumps({"A.COM": "https://aff.test/?id=1"}))
assert affiliate.rewrite("https://a.com/x") == "https://aff.test/?id=1"
def test_keys_lowercased(monkeypatch):
_set(monkeypatch, {"A.COM": {"param": "ref", "value": "1"}})
assert "ref=1" in affiliate.rewrite("https://a.com/x")
def test_summary_lists_configured_vendors(monkeypatch):
monkeypatch.setenv("LDR_AFFILIATES", json.dumps({
"a.com": "https://aff.test/1",
def test_summary_shows_modes(monkeypatch):
_set(monkeypatch, {
"a.com": {"param": "ref", "value": "1"},
"b.com": "https://aff.test/2",
}))
})
s = affiliate.summary()
assert "2 家" in s
assert "a.com" in s and "b.com" in s
assert "追加参数" in s and "整体替换" in s
# --------------------------------------------------------------------------
# 与 build_record 的集成
# 与 build_record 集成
# --------------------------------------------------------------------------
def test_build_record_applies_affiliate(monkeypatch):
"""配置推广链接后,记录里的 vendor_url 应被替换。"""
def test_build_record_applies_append_mode(monkeypatch):
from letdeals.extract import build_record
monkeypatch.setenv("LDR_AFFILIATES",
json.dumps({"dartnode.com":
"https://aff.test/?id=dart"}))
_set(monkeypatch, {"dartnode.com": {"param": "ref", "value": "X1"}})
item = {
"thread_id": "1", "source": "let",
"title": "DartNode Black Friday Deals",
"link": "https://lowendtalk.com/discussion/1/x",
"pubDate": "Fri, 03 Oct 2026 12:00:00 +0000",
"author": "dartnode",
"description": '<a href="https://dartnode.com/warehouse-deals">'
'deals</a>',
"description": '<a href="https://dartnode.com/warehouse-deals">d</a>',
}
r = build_record(item)
assert r["vendor_url"] == "https://aff.test/?id=dart"
assert r["vendor_url"] == "https://dartnode.com/warehouse-deals?ref=X1"
def test_build_record_without_affiliate_uses_original(monkeypatch):
"""没配置时保持原链接(不改变现有行为)。"""
def test_build_record_without_affiliate_unchanged(monkeypatch):
from letdeals.extract import build_record
item = {
"thread_id": "2", "source": "let",
"title": "DartNode Black Friday Deals",
"link": "https://lowendtalk.com/discussion/2/x",
"pubDate": "Fri, 03 Oct 2026 12:00:00 +0000",
"author": "dartnode",
"description": '<a href="https://dartnode.com/warehouse-deals">'
'deals</a>',
"description": '<a href="https://dartnode.com/warehouse-deals">d</a>',
}
r = build_record(item)
assert r["vendor_url"] == "https://dartnode.com/warehouse-deals"