拆分抓取与交易服务
把需要账号登录态的链路从抓取服务里拆出成独立进程。分界线不是「要不要登录」, 而是抓取无状态、幂等、可多开实例,而交易的写操作不可逆、登录态全局唯一、 订单监控是常驻轮询——同进程时抓取一扩容就会复制出 N 份登录态与 N 个轮询, 同一账号会被并发操作。 - app/shared:配置、错误码、日志、ApiResponse 信封 + Bearer 鉴权 + 异常处理器、 导航请求头构造器 - app/scraping:站点常量、会话、解析器与 10 个抓取接口,:31107,可多开 - app/trading:登录态查询/重载与健康检查,:31108,只能单实例 - 依赖方向锁为 scraping→shared、trading→shared,两侧互不 import; tests/test_architecture.py 用 AST 检查 import 并校验两个 app 的路径不串 - 登录态 UA 在 trading 独立持有:与抓取 UA 值相同但变更理由不同,抓取 UA 为绕 反爬可随时调整,登录 UA 一改可能触发设备校验使已落盘 cookie 失效 - scripts/login.py 与 AuthSession 共用 auth_site.PROFILES 与 is_logged_in,判据只写一遍 - 同一镜像两个启动命令,交易容器覆盖 command 并设 RAKUTEN_HEALTH_PORT 同时带上此前未提交的 ラクマ 分类接口与登录态基础设施。 验证:239 个离线用例全绿;两个入口真实启动,/health 与鉴权正常。 未验证:真实探测登录态(当前开发机无外网,对站点的连接全部超时)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
"""应用异常定义:所有业务异常均继承自 AppError
|
||||
|
||||
错误码规范:
|
||||
- 1xxx: 请求/鉴权错误
|
||||
- 2xxx: 抓取资源错误
|
||||
- 3xxx: 反爬/上游阻断相关错误
|
||||
- 4xxx: 页面解析错误
|
||||
- 5xxx: 加购/下单错误(需要账号登录态的写操作)
|
||||
"""
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
"""应用基础异常,携带错误码、HTTP 状态码和是否可重试信息"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
code: str,
|
||||
err_code: int,
|
||||
retryable: bool = False,
|
||||
status_code: int = 400,
|
||||
headers: dict[str, str] | None = None,
|
||||
):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.code = code
|
||||
self.err_code = err_code
|
||||
self.retryable = retryable
|
||||
self.status_code = status_code
|
||||
self.headers = headers
|
||||
|
||||
|
||||
class AuthenticationError(AppError):
|
||||
"""鉴权失败(Bearer Token 无效或缺失)"""
|
||||
|
||||
def __init__(self, message: str = "Invalid credentials"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
code="AUTH_INVALID",
|
||||
err_code=1001,
|
||||
retryable=False,
|
||||
status_code=401,
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
class InvalidRequestError(AppError):
|
||||
"""请求参数不合法(如 URL 非乐天站点、缺少必填标识)"""
|
||||
|
||||
def __init__(self, message: str = "Invalid request"):
|
||||
super().__init__(message=message, code="INVALID_REQUEST", err_code=1003, retryable=False)
|
||||
|
||||
|
||||
class ResourceBusyError(AppError):
|
||||
"""抓取资源繁忙(等待并发槽位超时)"""
|
||||
|
||||
def __init__(self, message: str = "Timed out while waiting for a scrape slot"):
|
||||
super().__init__(message=message, code="RESOURCE_BUSY", err_code=2002, retryable=True)
|
||||
|
||||
|
||||
class UpstreamRequestError(AppError):
|
||||
"""上游请求失败(网络异常、超时、5xx)"""
|
||||
|
||||
def __init__(self, message: str = "The upstream request failed"):
|
||||
super().__init__(message=message, code="UPSTREAM_ERROR", err_code=3001, retryable=True)
|
||||
|
||||
|
||||
class UpstreamBlockedError(AppError):
|
||||
"""上游请求被反爬阻断(Akamai 挑战页 / 403 / 页面缺少渲染数据)"""
|
||||
|
||||
def __init__(self, message: str = "The upstream request was blocked"):
|
||||
super().__init__(message=message, code="UPSTREAM_BLOCKED", err_code=3002, retryable=True)
|
||||
|
||||
|
||||
class ItemNotFoundError(AppError):
|
||||
"""商品不存在或已下架(详情页 404)"""
|
||||
|
||||
def __init__(self, message: str = "Item not found"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
code="ITEM_NOT_FOUND",
|
||||
err_code=4004,
|
||||
retryable=False,
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
|
||||
class ScrapeParseError(AppError):
|
||||
"""页面解析失败"""
|
||||
|
||||
def __init__(self, message: str = "Failed to parse the target page"):
|
||||
super().__init__(message=message, code="PARSE_ERROR", err_code=4001, retryable=False)
|
||||
|
||||
|
||||
class OffIchibaRedirectError(AppError):
|
||||
"""商品页跳转到了市场之外的乐天官方子站
|
||||
|
||||
楽天ブックス(book → books.rakuten.co.jp)、ビックカメラ
|
||||
(biccamera → biccamera.rakuten.co.jp)等官方旗舰店有各自独立的站点与
|
||||
页面结构,不返回市场统一模板,本服务的详情解析不适用。
|
||||
|
||||
单独成一类错误是为了让上游能把这些商品路由到别处,而不是当成被反爬拦截去重试。
|
||||
"""
|
||||
|
||||
def __init__(self, requested_url: str, final_url: str):
|
||||
super().__init__(
|
||||
message=(
|
||||
f"商品页跳转至乐天市场以外的站点,当前不支持解析:"
|
||||
f"{requested_url} -> {final_url}"
|
||||
),
|
||||
code="OFF_ICHIBA_REDIRECT",
|
||||
err_code=4002,
|
||||
retryable=False,
|
||||
)
|
||||
self.requested_url = requested_url
|
||||
self.final_url = final_url
|
||||
|
||||
|
||||
class NotLoggedInError(AppError):
|
||||
"""账号登录态缺失或已失效
|
||||
|
||||
加购与下单必须带已登录的账号会话。两站登录都要过 reCAPTCHA / 设备验证,
|
||||
无法自动恢复,因此这里明确标记 retryable=False,让上游停下来走一次
|
||||
`scripts/login.py` 重新人工登录,而不是原地重试。
|
||||
"""
|
||||
|
||||
def __init__(self, site: str, detail: str = ""):
|
||||
suffix = f"({detail})" if detail else ""
|
||||
super().__init__(
|
||||
message=(
|
||||
f"{site} 账号未登录或登录态已失效{suffix},"
|
||||
f"请运行 scripts/login.py --site {site} 重新登录"
|
||||
),
|
||||
code="NOT_LOGGED_IN",
|
||||
err_code=5001,
|
||||
retryable=False,
|
||||
status_code=401,
|
||||
)
|
||||
self.site = site
|
||||
self.detail = detail
|
||||
|
||||
|
||||
class CartOperationError(AppError):
|
||||
"""加购失败
|
||||
|
||||
站点对加购请求几乎不返回结构化错误:缺必填选项、SKU 已售罄、商品下架
|
||||
都可能返回 200 并把用户导回商品页。因此判定依据是「加购后购物车里有没有
|
||||
这件商品」,而不是 HTTP 状态码。
|
||||
"""
|
||||
|
||||
def __init__(self, message: str = "加入购物车失败"):
|
||||
super().__init__(message=message, code="CART_FAILED", err_code=5002, retryable=False)
|
||||
|
||||
|
||||
class OrderOperationError(AppError):
|
||||
"""下单流程失败(确认页解析不出、金额校验不通过、提交被拒等)"""
|
||||
|
||||
def __init__(self, message: str = "下单失败"):
|
||||
super().__init__(message=message, code="ORDER_FAILED", err_code=5003, retryable=False)
|
||||
|
||||
|
||||
class OrderGuardError(AppError):
|
||||
"""下单安全闸门未通过
|
||||
|
||||
真实付款不可逆,因此把「调用方没有显式确认」「实际金额超出上限」这类拦截
|
||||
单独成一类错误,与站点侧失败区分开——前者是本服务主动拒绝,重试无意义,
|
||||
需要调用方修改入参后再来。
|
||||
"""
|
||||
|
||||
def __init__(self, message: str):
|
||||
super().__init__(message=message, code="ORDER_GUARD", err_code=5004, retryable=False)
|
||||
Reference in New Issue
Block a user