Files
gungun88 59ac735751 feat(affiliate): 联盟推广链接映射表(默认不启用)
需求:把厂商链接换成自己的推广链接,用户下单拿佣金。
现在拿到的都是商家的原始链接(UTM/优惠码),不是返佣链接,
要拿佣金必须自己申请。

新增 letdeals/affiliate.py
- 域名 -> 完整推广链接的映射
- 默认空表:不配置就完全不影响现有行为
- 匹配规则:精确域名 + 忽略 www + 子域归并
  (my./billing./portal. 这类购买子域会命中父域规则)
- 子域规则优先于父域

刻意不做参数拼接:每家 AFF 格式不同(?aff= / ?ref= / /go/xxx),
拼错会丢佣金且很难发现,因此要求给**完整链接**。

容错:JSON 写错静默回退空表,最坏只是没有推广链接,
不会导致抓取或建站失败。

接入
- extract.build_record 里对 vendor_url 应用 rewrite
- cli 在字段覆盖率前提示已配置几家(未配置时不输出)

文档 docs/AFFILIATE.md 记录:现状为何没有 AFF、申请流程、
匹配规则、合规提醒(多数厂商禁止自购返佣/脚本注册/竞品词广告)。

验证:228 测试通过(新增 15 个),默认行为零变化。
2026-10-18 00:21:59 +08:00

784 lines
30 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""字段抽取:规格、价格、折扣、活动性质、地点。
全部为确定性正则(无 LLM),便于测试与复现。
"""
from __future__ import annotations
import re
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from . import affiliate, config
from .textutil import parse_flat_tables, strip_html, to_float, to_gb, to_tb
# --------------------------------------------------------------------------
# 正则:规格
# --------------------------------------------------------------------------
RE_VCPU = re.compile(
r"(\d+)\s*(?:x\s*)?(?:vCore|vCores|vCPU|vCPUs|vcore|vcpu)\b", re.I)
RE_CORE = re.compile(
r"(\d+)\s*(?:x\s*)?(?:Cores?|Kerne|核心)\b", re.I)
RE_RAM = re.compile(
r"(\d+(?:[.,]\d+)?)\s*(GB|MB|TB)\s*(?:of\s+)?(?:DDR[45]\s*)?(?:ECC\s*)?"
r"(?:RAM|Memory|Arbeitsspeicher)\b", re.I)
RE_RAM_LABEL = re.compile(
r"\bRAM\s*[::]\s*(\d+(?:[.,]\d+)?)\s*(GB|MB|TB)", re.I)
RE_STORAGE = re.compile(
r"(\d+(?:[.,]\d+)?)\s*(GB|TB)\s*"
r"(NVMe(?:\s*(?:Gen\s*\d|SSD))?|SSD|HDD|SATA|Storage)\b", re.I)
RE_BW = re.compile(
r"(\d+(?:[.,]\d+)?)\s*(TB|GB)\s*(?:of\s*)?(?:Monthly\s*)?"
r"(?:Traffic|Bandwidth|BW|transfer|outbound)\b", re.I)
RE_BW_AT = re.compile(r"(\d+(?:[.,]\d+)?)\s*(TB|GB)\s*@", re.I)
# CPU 型号(保守:只认常见服务器 CPU 家族)
RE_CPU_MODEL = re.compile(
r"\b("
r"EPYC\s+[0-9]{3,4}[A-Za-z]{0,3}"
r"|Ryzen\s+(?:9|7|5|3)\s+[0-9]{3,4}[A-Za-z0-9]{0,4}"
r"|Threadripper\s+[0-9A-Za-z]{3,8}(?:\s+Pro)?"
r"|Xeon\s+(?:Gold|Silver|Bronze|Platinum|E5-|E-)[0-9A-Za-z]{2,6}"
r")\b", re.I)
# --------------------------------------------------------------------------
# 正则:价格
# --------------------------------------------------------------------------
CYCLE = (r"(mo|mos|month|months|monthly|yr|yrs|year|years|yearly|"
r"annual|annually|quarter|quarterly|qtr)")
RE_PRICE_PRE = re.compile(
r"([$€£])\s*(\d+(?:[.,]\d+)?)\s*(?:USD|EUR|GBP)?\s*(?:/|per\s+)\s*"
+ CYCLE, re.I)
RE_PRICE_POST = re.compile(
r"(\d+(?:[.,]\d+)?)\s*([$€£]|USD|EUR|GBP)\s*(?:/|per\s+)\s*" + CYCLE, re.I)
CYCLE_NORM = {
"mo": "month", "mos": "month", "month": "month", "months": "month",
"monthly": "month",
"yr": "year", "yrs": "year", "year": "year", "years": "year",
"yearly": "year", "annual": "year", "annually": "year",
"quarter": "quarter", "quarterly": "quarter", "qtr": "quarter",
}
CURRENCY_SYMBOL = {"USD": "$", "EUR": "€", "GBP": "£"}
# --------------------------------------------------------------------------
# 正则:折扣 / 优惠码
# --------------------------------------------------------------------------
RE_PCT = re.compile(
r"(\d{1,3})\s*%\s*(?:off|discount|rabatt|reduction|savings?)\b"
r"|\b(?:off|discount|save|savings?\s+of)\s+(\d{1,3})\s*%", re.I)
RE_RECURRING = re.compile(
r"\brecurring\b|\blifetime\b|\bfor\s+life\b|\bpermanent(?:ly)?\b|"
r"\bprice\s+locked\b|\b循环\b|\b永久\b|\b续费同价\b", re.I)
RE_CODE = re.compile(
r"(?:coupon|promo(?:tion)?\s*code|code|gutschein)\b[^A-Za-z0-9]{0,25}"
r"([A-Z][A-Z0-9]{2,25})", re.I)
RE_CODE_BARE = re.compile(
r"\b(LET[A-Z0-9]{2,20}|BF[A-Z0-9]{2,20}|LEB[A-Z0-9]{2,20})\b")
# 误报黑名单:这些「大写词」不是优惠码
CODE_STOP = {
"CODE", "CODES", "DISCOUNT", "REQUIRED", "NEEDED", "APPLICABLE",
"AVAILABLE", "EXPIRES", "PROMO", "COUPON", "GUTSCHEIN", "FREE",
"PLAN", "PLANS", "VPS", "SSD", "NVME", "HDD", "RAM", "CPU", "USD",
"EUR", "GBP", "IPV4", "IPV6", "DDR4", "DDR5", "AMD", "INTEL", "XEON",
"EPYC", "RYZEN", "WIN10", "WIN11", "WIN2019", "WIN2022", "LET", "GET",
"USE", "THE", "AND", "FOR", "ALL", "NEW", "OFF", "ONE", "TWO", "ADD",
"MONTH", "YEAR", "SAVE", "EXTRA", "BONUS", "DEAL", "SALE", "ORDER",
}
RE_STOCK = re.compile(
r"\b(out\s+of\s+stock|sold\s*out|restock(?:ed|ing)?|back\s+in\s+stock|"
r"in\s+stock|no\s+further\s+restocks|limited\s+stock|while\s+stock\s+lasts|"
r"售罄|缺货|补货|有货)\b", re.I)
RE_EXTRAS = re.compile(
r"(double\s+(?:ram|bandwidth|storage|traffic|ssd|nvme|cores?|vcpu)|"
r"triple\s+\w+|free\s+(?:permanent\s+)?upgrade|"
r"\d+\s+months?\s+free|"
r"12\s+months?\s+for\s+the\s+price\s+of\s+10|"
r"免费(?:升级|加倍))", re.I)
# --------------------------------------------------------------------------
# 活动性质
# --------------------------------------------------------------------------
EVENT_RULES = [
("anniversary", re.compile(
r"anniversary|birthday|\b\d+\s*(?:th|st|nd|rd)\s+(?:years?|birthday)|"
r"years?\s+in\s+business|周年|庆典|jubil", re.I)),
("black_friday", re.compile(
r"black\s*friday|\bBF20\d\d\b|cyber\s*monday|黑五", re.I)),
("holiday", re.compile(
r"christmas|halloween|easter|national\s*day|mid-?autumn|new\s*year|"
r"advent|summer\s*sale|end\s*of\s*summer|freedom\s*sale|festival|"
r"中秋|国庆|圣诞|春节", re.I)),
("launch", re.compile(
r"\blaunch|is\s+back|returns?\b|new\s+location|first\s+(?:deal|vps)|"
r"now\s+live|debut|上新|开业", re.I)),
("restock", re.compile(r"restock|back\s+in\s+stock|补货", re.I)),
("sale", re.compile(
r"\bsale\b|\bdeal\b|\bpromo|\bdiscount|flash\s*sale|clearance|budget|"
r"special\s+offer|特价|促销|优惠", re.I)),
]
EVENT_CN = {
"anniversary": "周年庆", "black_friday": "黑五", "holiday": "节日促销",
"launch": "上新/回归", "restock": "补货", "sale": "常规促销",
"regular": "常规帖",
}
AVAIL_CN = {
"ON_SALE": "在售", "SOLD_OUT": "售罄", "ENDED": "活动结束",
"RESTOCKING": "补货中", "PARTIAL": "部分售罄",
"LIKELY_SOLD_OUT": "疑似售罄",
"UNVERIFIED": "待确认", "UNVERIFIED_STALE": "待确认(较旧)",
}
# --------------------------------------------------------------------------
# 地点
# --------------------------------------------------------------------------
LOCATIONS = [
"Frankfurt", "Amsterdam", "Netherlands", "Germany", "Deutschland",
"London", "Paris", "France", "Singapore", "Tokyo", "Japan",
"Hong Kong", "Los Angeles", "New York", "Miami", "Dallas", "Chicago",
"Seattle", "Montreal", "Canada", "Poland", "Warsaw", "Wrocław",
"Zurich", "Switzerland", "Velbert", "Eygelshoven", "Roubaix",
"Salt Lake City", "Buffalo", "Ashburn", "Atlanta", "Phoenix",
"Arizona", "Ohio", "Texas", "Utah", "Milan", "Italy", "Spain",
"Sweden", "Norway", "Finland", "Denmark", "Austria", "Vienna",
"Prague", "Czechia", "Estonia", "Latvia", "Lithuania", "Romania",
"Bulgaria", "India", "Australia", "Sydney", "Brazil", "Sao Paulo",
"Kansas", "Denver", "Detroit", "Silicon Valley", "Santa Clara",
]
RE_LOC = re.compile(
r"\b(" + "|".join(re.escape(x) for x in LOCATIONS) + r")\b", re.I)
# 域名回退时排除的通用/第三方站点
DOMAIN_SKIP = {
"lowendtalk.com", "lowendbox.com", "github.com", "youtube.com",
"discord.gg", "discord.com", "twitter.com", "x.com", "twitch.tv",
"google.com", "t.me", "telegram.org", "cloudflare.com", "imgur.com",
"i.imgur.com", "pastebin.com", "instatus.com", "trustpilot.com",
"statuspage.io", "facebook.com", "instagram.com", "reddit.com",
}
# 从文本里识别商家品牌的句式:
# "from Velohost" / "by RackNerd" / "Velohost Returns" / "Offer from X"
RE_BRAND_IN_TEXT = re.compile(
r"\b(?:from|by|via|at)\s+([A-Z][A-Za-z0-9\-\.]{2,28})\b"
r"|^([A-Z][A-Za-z0-9\-\.]{2,28})\s+(?:Returns?|is\s+Back|Back|"
r"Launches|Offers|Deals|Sale|Anniversary)\b", re.M)
# --------------------------------------------------------------------------
# 抽取函数
# --------------------------------------------------------------------------
def extract_provider(title: str, author: str, body: str,
source: str = "let") -> str:
"""从标题取商家名。
优先用分隔符切出的首段;但很多帖子标题是描述性句子
(如 "Free Ryzen 5900XT Server | From $99/mo"),首段不是商家名。
此时回退到正文里的域名,最后才用作者名。
source="leb" 时**不能**回退到作者名 —— LEB 是博客,
作者是编辑(如 raindog308)而非商家,回退会让所有条目同名。
"""
head = re.split(r"\s*[|│|–—]\s*|\s+-\s+", title, maxsplit=1)[0].strip()
head = re.sub(r"^[\[\【((][^\]))]{0,30}[\]))]\s*", "", head).strip()
head = re.sub(r"\s*[:\-]\s*$", "", head)
# **仅对 LEB** 裁掉常见句式后缀(「X Returns With ...」「X: ...」)。
# LET 的标题首段往往就是商家名,套用此规则会把
# 「BERO HOST 10th Anniversary Deals」误裁成「BERO HOST 10th Anniversary」,
# 从而丢掉更精确的域名回退结果。
if source == "leb":
head = re.sub(
r"\s+\b(?:returns?|is\s+back|back|relaunch(?:es)?|deals?|sale|"
r"offer|promo(?:tion)?|launch(?:es)?)\b.*$", "",
head, flags=re.I).strip()
head = re.sub(r"\s*[:\-–—]\s*$", "", head).strip()
looks_descriptive = bool(
re.search(r"[$€£]\s*\d", head)
or re.search(r"\b(from|starting|for only|deals?|sale|off|free)\b",
head, re.I)
or len(head.split()) > 6
or re.search(r"\d+\s*(?:GB|TB|Cores?|vCPU|RAM)", head, re.I)
)
if 2 <= len(head) <= 60 and not looks_descriptive:
return head
# 回退 1:正文中的厂商域名(只取顶级域完整的)
for m in re.finditer(
r"https?://(?:www\.)?([a-z0-9][a-z0-9\-]*\.[a-z.]{2,10})",
body, re.I):
dom = m.group(1).lower().rstrip(".")
if dom in DOMAIN_SKIP or dom.endswith((".lowendtalk.com",
".lowendbox.com")):
continue
if re.fullmatch(r"[a-z0-9\-]+\.[a-z]{2,6}", dom):
return dom
# 回退 1b:允许带子域,裁掉常见前缀
for m in re.finditer(
r"https?://(?:www\.)?([a-z0-9][a-z0-9\-\.]*\.[a-z]{2,6})",
body, re.I):
dom = m.group(1).lower().rstrip(".")
if dom in DOMAIN_SKIP or dom.endswith((".lowendtalk.com",
".lowendbox.com")):
continue
parts = dom.split(".")
return ".".join(parts[-2:]) if len(parts) > 2 else dom
# 回退 2:LEB 的正文/标题里常直接写商家名(如 "from Velohost")。
# 作者是编辑,不能当商家名,因此优先从文本里找已知品牌词
if source == "leb":
m = RE_BRAND_IN_TEXT.search(title + "\n" + body[:600])
if m:
return (m.group(1) or m.group(2)).strip()
# 再退一步:用裁剩的标题首段
return head[:40] if head else "UNKNOWN"
return author or "UNKNOWN"
# --------------------------------------------------------------------------
# 厂商官网推断
# --------------------------------------------------------------------------
# 这些域名出现在帖子里也不代表厂商官网:跑分站、图床、短链、社交、
# 统计、建站依赖等。宁可少给链接,也不要指向错误的外链。
VENDOR_SKIP = DOMAIN_SKIP | {
"bit.ly", "tinyurl.com", "t.co", "linktr.ee", "gravatar.com",
"wordpress.org", "w.org", "gstatic.com", "googleapis.com",
"jsdelivr.net", "unpkg.com", "cdnjs.com", "bootstrapcdn.com",
"fontawesome.com", "creativecommons.org", "wikipedia.org",
"archive.org", "speedtest.net", "whatsmydns.net", "bgp.tools",
"bgp.he.net", "ipinfo.io", "ip.sb", "check.place", "hetrixtools.com",
"uptimerobot.com", "letsencrypt.org", "nginx.org",
"docker.com", "apache.org", "mysql.com", "redis.io", "php.net",
"ubuntu.com", "debian.org", "centos.org", "almalinux.org",
"rockylinux.org", "windows.com", "microsoft.com", "vmware.com",
"proxmox.com", "whmcs.com", "clientexec.com", "blesta.com",
"virtfusion.com", "solusvm.com", "virtualizor.com",
# 跑分/评测站:帖子里常贴成绩链接,很容易被误判为厂商
"geekbench.com", "cpubenchmark.net", "passmark.com", "userbenchmark.com",
"browser.geekbench.com", "diskbenchmark.com", "fio.readthedocs.io",
# 网络工具站
"ping.pe", "check-host.net", "ipip.net", "17ce.com", "boce.com",
}
# 常见的二级后缀:可注册域要取三段,否则 something.co.uk 会被切成 co.uk
MULTI_SUFFIX = {
"co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk",
"com.au", "net.au", "org.au", "co.nz", "net.nz", "org.nz",
"co.jp", "ne.jp", "or.jp", "com.cn", "net.cn", "org.cn", "gov.cn",
"com.br", "com.mx", "com.ar", "com.co", "com.pe", "com.ve",
"com.tw", "com.hk", "com.sg", "com.my", "com.ph", "com.vn",
"com.tr", "com.ua", "com.ru", "co.kr", "co.in", "co.id", "co.th",
"co.za", "co.il", "com.sa", "com.eg", "com.ng", "com.pk",
}
# 厂商名本身就是域名时的形态(如 bero-host.de / Royal-Server.Ro)
RE_IS_DOMAIN = re.compile(r"^[a-z0-9][a-z0-9\-]*(\.[a-z0-9\-]+)+$", re.I)
# 技术性主机名,不是厂商官网:lg=looking glass、speedtest、status 等
TECH_PREFIX = {
"lg", "speedtest", "test", "status", "panel", "cp", "cpanel", "whm",
"ns1", "ns2", "ns3", "dns", "mail", "smtp", "imap", "pop",
"api", "admin", "billing", "client", "clients", "portal", "support",
"help", "docs", "wiki", "blog", "forum", "community", "cdn", "static",
"mirror", "download", "downloads", "node", "nodes", "vps", "host",
}
def _key(s: str) -> str:
"""归一化用于相似度比较:只留小写字母数字。"""
return re.sub(r"[^a-z0-9]", "", (s or "").lower())
def _registrable(dom: str) -> str:
"""取可注册域,正确处理 co.uk 这类多段后缀。"""
parts = dom.lower().strip(".").split(".")
if len(parts) >= 3 and ".".join(parts[-2:]) in MULTI_SUFFIX:
return ".".join(parts[-3:])
return ".".join(parts[-2:]) if len(parts) > 2 else dom.lower()
def _plausible_domain(dom: str) -> bool:
"""粗筛:形如 label.tld,且 TLD 长度合理。
注意 TLD 上限不能按老式 6 字符设:.hosting/.solutions/.technology
等新顶级域更长,卡 6 会把 Ava.hosting 这类厂商判为非法。
"""
if not re.fullmatch(r"[a-z0-9\-]+(\.[a-z0-9\-]+)+", dom):
return False
tld = dom.rsplit(".", 1)[-1]
if not re.fullmatch(r"[a-z]{2,24}", tld):
return False
# 明显的技术前缀不作为厂商域(lg.svr、status.xxx 等)
first = dom.split(".", 1)[0]
if first in TECH_PREFIX and dom.count(".") == 1:
return False
return True
def extract_vendor_url(provider: str, body: str) -> str | None:
"""尽力推断厂商的**活动/购买页**,不确定时退回首页。
优先级(从具体到笼统):
1) 正文里与厂商域名匹配、且路径像「活动/购买页」的链接
2) 正文里与厂商域名匹配的任意深层链接
3) 厂商名本身就是域名 -> https://<provider>
4) 正文域名与厂商名匹配 -> https://<domain>
为什么优先深链接:原帖正文常直接给活动页或购买页,
例如 linceris.com/deals、deluxhost.net/en/cart?add=...。
只给首页会让用户还要自己找活动,体验差。
但**必须挑准**:正文里也夹杂 lg-xxx(测速)、status-xxx(状态页)、
twitch.tv(直播)等非购买链接,取第一个会错。
"""
p = (provider or "").strip()
if not p:
return None
body = body or ""
pk = _key(p)
# 收集正文外链,按「是否匹配厂商域名」筛选
cands: list[tuple[str, str]] = [] # (完整 URL, 域名)
for m in re.finditer(r'href=["\'](https?://[^"\']+)["\']', body, re.I):
url = m.group(1).replace("&amp;", "&").strip()
dm = re.match(r"https?://(?:www\.)?([a-z0-9][a-z0-9\-\.]*\.[a-z]{2,24})",
url, re.I)
if not dm:
continue
dom = _registrable(dm.group(1))
if not _plausible_domain(dom) or dom in VENDOR_SKIP:
continue
if dom.endswith((".lowendtalk.com", ".lowendbox.com")):
continue
cands.append((url, dom))
# 与厂商名匹配的候选
matched: list[str] = []
if len(pk) >= 4:
for url, dom in cands:
dk = _key(dom.rsplit(".", 1)[0])
if len(dk) >= 4 and (dk == pk or dk in pk or pk in dk):
if url not in matched:
matched.append(url)
# 1) 优先「像活动/购买页」的深链接
for url in matched:
if _looks_like_offer_page(url):
return url
# 2) 次选:深层链接,但必须排除辅助页与技术页
# (第 2 档比第 1 档宽松,容易被 /docs/、/terms、测速文件混进来,
# 实测遇到 ravnix.gg/terms、servury.com/docs/...、mtl-lg.servury.com/100MB.bin)
for url in matched:
if _path_depth(url) > 0 and _is_usable_deep_link(url):
return url
# 3) 厂商名本身就是域名 -> 首页
if RE_IS_DOMAIN.match(p) and _plausible_domain(p.lower()):
dom = _registrable(p)
if _plausible_domain(dom) and dom not in VENDOR_SKIP:
return "https://" + dom
# 4) 匹配到的首页(仅根路径)
for url in matched:
if _path_depth(url) == 0:
return url
return None
# 路径里出现这些词,说明是活动/购买/套餐页,而不是首页或关于页
OFFER_HINTS = (
"deal", "deals", "sale", "sales", "promo", "offer", "offers",
"special", "discount", "pricing", "price", "plans", "plan",
"order", "cart", "store", "buy", "checkout", "shop",
"vps", "kvm", "server", "servers", "hosting", "cloud",
)
# 这些路径段说明是辅助页面,不是购买入口
NON_OFFER_HINTS = (
"legal", "tos", "privacy", "sla", "about", "contact", "blog",
"status", "support", "help", "docs", "wiki", "faq", "terms",
"policy", "career", "careers", "login", "signin", "register",
"signup", "logout", "account", "profile",
# 合规/条款类(实测踩到 ravnix.gg/aup)
"aup", "acceptable-use", "refund", "dmca", "abuse", "gdpr",
"cookie", "disclaimer", "imprint",
)
def _path_depth(url: str) -> int:
"""URL 的路径深度:首页返回 0,/deals 返回 1。"""
m = re.match(r"https?://[^/]+(/[^?#]*)?", url)
if not m:
return 0
return len([x for x in (m.group(1) or "").strip("/").split("/") if x])
def _looks_like_offer_page(url: str) -> bool:
"""判断 URL 是否指向活动/购买/套餐页。
要求:有路径,且路径中含 offer 类关键词,且不含 legal/status 等
辅助页关键词。宁可退回首页,也不要把「隐私政策」当购买入口。
"""
if _path_depth(url) == 0:
return False
low = url.lower()
if any(h in low for h in NON_OFFER_HINTS):
return False
return any(h in low for h in OFFER_HINTS)
# 明显不是页面入口的:二进制文件、测速文件
JUNK_EXT = (".bin", ".zip", ".gz", ".tar", ".iso", ".img", ".txt",
".exe", ".dmg", ".deb", ".rpm", ".test", ".dat")
# 技术/辅助子域:测速、状态、文档、CDN
JUNK_SUBDOMAIN = ("lg-", "lg.", "speedtest", "test.", "status", "status.",
"docs", "docs.", "wiki", "wiki.", "cdn", "cdn.",
"static", "static.", "mirror", "mirror.")
def _is_usable_deep_link(url: str) -> bool:
"""深链接是否可用作「购买/活动」入口。
排除三类实测踩到的干扰项:
1) 二进制/测速文件 —— mtl-lg.servury.com/100MB.bin
2) 技术子域 —— lg-fra.servitro.com、status.servitro.com
3) 辅助页关键词 —— /terms、/docs/、/legal
"""
low = url.lower()
# 路径是二进制文件
path = re.sub(r"^https?://[^/]+", "", low).split("?")[0]
if path.endswith(JUNK_EXT):
return False
# 技术子域
host = re.sub(r"^https?://", "", low).split("/")[0]
if any(host.startswith(s) or ("." + s) in host for s in JUNK_SUBDOMAIN):
return False
# 辅助页关键词
if any(h in low for h in NON_OFFER_HINTS):
return False
return True
def extract_specs(text: str) -> dict:
vcpu = [int(m) for m in RE_VCPU.findall(text)]
if not vcpu:
vcpu = [int(m) for m in RE_CORE.findall(text)]
ram = [to_gb(to_float(a), u) for a, u in RE_RAM.findall(text)]
ram += [to_gb(to_float(a), u) for a, u in RE_RAM_LABEL.findall(text)]
storage = []
for a, u, kind in RE_STORAGE.findall(text):
kind_n = re.sub(r"\s+", " ", kind).strip()
if kind_n.lower() == "storage":
kind_n = "unspecified"
storage.append({"gb": to_gb(to_float(a), u), "type": kind_n})
bw = [to_tb(to_float(a), u) for a, u in RE_BW.findall(text)]
bw += [to_tb(to_float(a), u) for a, u in RE_BW_AT.findall(text)]
def top(vals):
return max(vals) if vals else None
storage_sorted = sorted(storage, key=lambda x: x["gb"], reverse=True)
flagship = storage_sorted[0] if storage_sorted else None
return {
"vcpu": top(vcpu),
"ram_gb": top(ram),
"storage_gb": flagship["gb"] if flagship else None,
"storage_type": flagship["type"] if flagship else None,
"bandwidth_tb": top(bw),
"_all": {
"vcpu": sorted(set(vcpu), reverse=True)[:8],
"ram_gb": sorted(set(ram), reverse=True)[:8],
"storage_gb": [s["gb"] for s in storage_sorted][:8],
},
}
def extract_prices(text: str) -> list[dict]:
found = []
for cur, amt, cyc in RE_PRICE_PRE.findall(text):
found.append({"amount": to_float(amt), "currency": cur,
"cycle": CYCLE_NORM.get(cyc.lower(), cyc.lower())})
for amt, cur, cyc in RE_PRICE_POST.findall(text):
c = cur if cur in "$€£" else CURRENCY_SYMBOL.get(cur.upper(), cur)
found.append({"amount": to_float(amt), "currency": c,
"cycle": CYCLE_NORM.get(cyc.lower(), cyc.lower())})
mult = {"month": 12, "quarter": 4, "year": 1}
seen, uniq = set(), []
for p in found:
key = (p["amount"], p["currency"], p["cycle"])
if key in seen:
continue
seen.add(key)
uniq.append(p)
uniq.sort(key=lambda p: p["amount"] * mult.get(p["cycle"], 12))
return uniq
def extract_discount(text: str) -> dict:
pcts = []
for a, b in RE_PCT.findall(text):
v = a or b
if v and 0 < int(v) <= 95:
pcts.append(int(v))
recurring = bool(RE_RECURRING.search(text))
codes = set()
for m in RE_CODE.findall(text):
u = m.upper()
if u not in CODE_STOP and not u.isdigit():
codes.add(u)
for m in RE_CODE_BARE.findall(text):
u = m.upper()
if u not in CODE_STOP:
codes.add(u)
extras = sorted({m.group(0).strip().lower()
for m in RE_EXTRAS.finditer(text)})
parts = []
if recurring:
parts.append("循环/永久")
if pcts:
parts.append(f"最高 {max(pcts)}% off")
if extras:
parts.append("+".join(extras[:2]))
if codes:
parts.append("码 " + "/".join(sorted(codes)[:3]))
return {
"strength": " · ".join(parts) if parts else "",
"percent_max": max(pcts) if pcts else None,
"recurring": recurring,
"codes": sorted(codes)[:6],
"extras": extras[:5],
}
def extract_event(title: str, body: str) -> str:
hay = title + "\n" + body[:1500]
for name, rx in EVENT_RULES:
if rx.search(hay):
return name
return "regular"
def extract_locations(text: str) -> list[str]:
seen, out = set(), []
for m in RE_LOC.finditer(text):
v = m.group(1)
k = v.lower()
if k not in seen:
seen.add(k)
out.append(v)
return out[:5]
def extract_cpu_models(text: str) -> list[str]:
return sorted({re.sub(r"\s+", " ", m).strip()
for m in RE_CPU_MODEL.findall(text)})[:5]
def extract_stock_hints(text: str) -> list[str]:
return sorted({m.group(0).strip() for m in RE_STOCK.finditer(text)})
def age_days(pub: str) -> int | None:
try:
dt = parsedate_to_datetime(pub)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return max(0, (datetime.now(timezone.utc) - dt).days)
except Exception:
return None
def age_status(days: int | None) -> str:
if days is None:
return "UNKNOWN"
if days < config.NEW_DAYS:
return "NEW"
if days <= config.ACTIVE_DAYS:
return "ACTIVE"
return "STALE"
def availability(stock_hints: list[str], days: int | None,
reply_status: str | None = None,
reply_source: str | None = None) -> str:
"""可用性判定。
优先级:**商家回帖表态 > 帖文关键词 > 用户回帖(弱证据) > 帖子年龄**。
回帖是库存的权威来源 —— 商家只在回帖里说「售罄 / 补货」。
"""
if reply_source == "vendor" and reply_status:
return reply_status if reply_status in (
"SOLD_OUT", "RESTOCKING", "ENDED", "ON_SALE", "PARTIAL") \
else "UNVERIFIED"
blob = " ".join(stock_hints).lower()
if re.search(r"sold\s*out|out\s+of\s+stock|售罄|缺货", blob):
return "SOLD_OUT"
if re.search(r"no\s+further\s+restocks", blob):
return "ENDED"
if re.search(r"restock|back\s+in\s+stock|in\s+stock|补货|有货", blob):
return "ON_SALE"
if reply_source == "user" and reply_status == "SOLD_OUT":
return "LIKELY_SOLD_OUT"
if days is not None and days > config.ACTIVE_DAYS:
return "UNVERIFIED_STALE"
return "UNVERIFIED"
# --------------------------------------------------------------------------
# 记录构建
# --------------------------------------------------------------------------
KEY_FIELDS = ("vcpu", "ram_gb", "storage_gb", "prices", "cpu_models", "discount")
def build_record(item: dict, reply_info: dict | None = None) -> dict:
"""把一个 RSS item 变成结构化记录。"""
raw_body = item.get("description", "") or ""
body = strip_html(raw_body)
text = item.get("title", "") + "\n" + body
specs = extract_specs(text)
plans = parse_flat_tables(body)
prices = extract_prices(text)
discount = extract_discount(text)
cpu_models = extract_cpu_models(text)
locs = extract_locations(item.get("title", "") + "\n" + body[:800])
stock = extract_stock_hints(text)
days = age_days(item.get("pubDate", ""))
# 表格解析成功时,用表格里的旗舰套餐覆盖/补齐 specs(更准确)
flagship_price = None
if plans:
top_plan = max(plans, key=lambda p: (p["ram_gb"] or 0, p["vcpu"] or 0))
if top_plan["vcpu"]:
specs["vcpu"] = top_plan["vcpu"]
if top_plan["ram_gb"]:
specs["ram_gb"] = top_plan["ram_gb"]
if top_plan["storage_gb"]:
specs["storage_gb"] = top_plan["storage_gb"]
specs["storage_type"] = top_plan["storage_type"]
if top_plan["bandwidth_tb"]:
specs["bandwidth_tb"] = top_plan["bandwidth_tb"]
tp = top_plan.get("price")
if tp:
flagship_price = {"amount": tp["amount"],
"currency": tp["currency"], "cycle": "year"}
prices = [flagship_price] + [
p for p in prices
if not (abs(p["amount"] - tp["amount"]) < 1e-9
and p["currency"] == tp["currency"])
]
specs["_all"] = {
"vcpu": sorted({p["vcpu"] for p in plans if p["vcpu"]},
reverse=True),
"ram_gb": sorted({p["ram_gb"] for p in plans if p["ram_gb"]},
reverse=True),
"storage_gb": sorted({p["storage_gb"] for p in plans
if p["storage_gb"]}, reverse=True),
}
filled = sum([
specs["vcpu"] is not None,
specs["ram_gb"] is not None,
specs["storage_gb"] is not None,
bool(prices),
bool(cpu_models),
bool(discount["strength"]),
])
confidence = round(filled / len(KEY_FIELDS), 2)
reply_status = reply_source = None
reply_block = None
if reply_info and reply_info.get("ok"):
reply_status = reply_info.get("reply_status")
reply_source = reply_info.get("reply_source")
reply_block = {
"reply_count": reply_info.get("reply_count"),
"vendor_reply_count": reply_info.get("vendor_reply_count"),
"status": reply_status,
"source": reply_source,
"evidence": reply_info.get("reply_evidence"),
"vendor_signals": reply_info.get("vendor_signals", []),
"fetched_at": reply_info.get("fetched_at"),
}
provider = extract_provider(item.get("title", ""),
item.get("author", ""), body,
item.get("source", "let"))
# 厂商链接:优先取原帖里的活动/购买页;若该厂商配置了推广链接
# (letdeals/affiliate.py),则替换成你的推广链接。
vendor_url = extract_vendor_url(provider, raw_body)
vendor_url = affiliate.rewrite(vendor_url)
return {
"thread_id": str(item.get("thread_id", "")),
"source": item.get("source", "let"),
"provider": provider,
"title": item.get("title", ""),
"url": item.get("link", ""),
# 必须用**原始 HTML** 抽取:strip_html 会把 <a href> 一并剥掉,
# 而厂商链接恰恰只存在于 href 里。
"vendor_url": vendor_url,
"author": item.get("author", ""),
"posted": item.get("pubDate", ""),
"age_days": days,
"age_status": age_status(days),
"availability": availability(stock, days, reply_status, reply_source),
"replies": reply_block,
"event_type": extract_event(item.get("title", ""), body),
"discount": discount,
"specs": {k: v for k, v in specs.items() if not k.startswith("_")},
"specs_all": specs["_all"],
"plans": plans[:40],
"flagship_price": flagship_price,
"cpu_models": cpu_models,
"prices": prices[:4],
"locations": locs,
"stock_hints": stock,
"confidence": confidence,
}