拆分抓取与交易服务
把需要账号登录态的链路从抓取服务里拆出成独立进程。分界线不是「要不要登录」, 而是抓取无状态、幂等、可多开实例,而交易的写操作不可逆、登录态全局唯一、 订单监控是常驻轮询——同进程时抓取一扩容就会复制出 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:
@@ -1,42 +0,0 @@
|
||||
"""FastAPI 依赖注入:提供容器获取和鉴权校验"""
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from fastapi import Depends, Request
|
||||
|
||||
from app.core.container import ServiceContainer
|
||||
from app.core.errors import AuthenticationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_container(request: Request) -> ServiceContainer:
|
||||
"""从请求中获取服务容器"""
|
||||
return request.app.state.container
|
||||
|
||||
|
||||
def require_bearer_token(
|
||||
request: Request,
|
||||
container: ServiceContainer = Depends(get_container),
|
||||
) -> None:
|
||||
"""Bearer Token 鉴权依赖
|
||||
|
||||
从请求头 Authorization 中提取 Bearer Token,
|
||||
与服务端配置的 token 做安全比较(使用 secrets.compare_digest 防止时序攻击)。
|
||||
"""
|
||||
authorization = request.headers.get("Authorization")
|
||||
if not authorization:
|
||||
logger.warning("鉴权失败:缺少 Authorization 请求头")
|
||||
raise AuthenticationError("Missing Authorization header")
|
||||
|
||||
token = authorization.replace("Bearer ", "", 1).strip()
|
||||
if token == authorization:
|
||||
logger.warning("鉴权失败:Authorization scheme 非 Bearer")
|
||||
raise AuthenticationError("Invalid Authorization scheme")
|
||||
if not token:
|
||||
logger.warning("鉴权失败:Bearer token 为空")
|
||||
raise AuthenticationError("Invalid token")
|
||||
|
||||
if not secrets.compare_digest(token, container.settings.bearer_token):
|
||||
logger.warning("鉴权失败:token 不匹配")
|
||||
raise AuthenticationError("Invalid token")
|
||||
@@ -1,28 +0,0 @@
|
||||
"""服务容器:集中管理所有服务实例,用于依赖注入"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.services.browser_fallback import BrowserFallback
|
||||
from app.services.rakuma_client import RakumaClient
|
||||
from app.services.rakuma_session import RakumaSession
|
||||
from app.services.rakuten_client import RakutenClient
|
||||
from app.services.site_session import SiteSession
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ServiceContainer:
|
||||
"""服务容器,持有所有核心服务实例
|
||||
|
||||
通过 FastAPI 的 app.state.container 在请求间共享,
|
||||
各路由通过依赖注入获取容器中的服务。
|
||||
|
||||
两个站点各自持有独立的会话与客户端:乐天需要 Akamai cookie 预热与双指纹
|
||||
通道,ラクマ 不需要,抓取前提不同不便合并。
|
||||
"""
|
||||
|
||||
settings: Settings
|
||||
browser_fallback: BrowserFallback
|
||||
site_session: SiteSession
|
||||
rakuten_client: RakutenClient
|
||||
rakuma_session: RakumaSession
|
||||
rakuma_client: RakumaClient
|
||||
@@ -1,8 +0,0 @@
|
||||
"""ラクマ(fril.jp)页面解析器
|
||||
|
||||
站点是服务端渲染的 HTML,没有内联状态 JSON,因此各模块都走 DOM 解析:
|
||||
- base — 埋点属性与文本取值的公共工具
|
||||
- search — 搜索页(商品卡片解析同时被店铺页复用)
|
||||
- item — 商品详情页
|
||||
- shop — 店铺页与评价页
|
||||
"""
|
||||
@@ -1,21 +1,23 @@
|
||||
"""健康检查路由"""
|
||||
"""抓取服务健康检查路由"""
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.dependencies import get_container
|
||||
from app.core.container import ServiceContainer
|
||||
from app.models.scrape import ApiResponse, HealthData
|
||||
from app.scraping.container import ScrapingContainer
|
||||
from app.scraping.models.scrape import HealthData
|
||||
from app.shared.api import ApiResponse, get_container
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health", response_model=ApiResponse[HealthData])
|
||||
async def health(container: ServiceContainer = Depends(get_container)) -> ApiResponse[HealthData]:
|
||||
"""服务健康状态
|
||||
async def health(container: ScrapingContainer = Depends(get_container)) -> ApiResponse[HealthData]:
|
||||
"""抓取服务健康状态
|
||||
|
||||
sessions 里给出乐天的 PC / 手机两条抓取通道的 cookie 预热情况,以及
|
||||
ラクマ 通道的就绪状态(ラクマ 无需预热,只报是否已初始化);
|
||||
browser_fallback_* 反映浏览器兜底当前是否可用(未安装 playwright 时为不可用,
|
||||
属于预期降级,不影响主链路)。
|
||||
|
||||
账号登录态不在这里——它属于交易服务,查它请打交易服务的 /health。
|
||||
"""
|
||||
return ApiResponse[HealthData](
|
||||
success=True,
|
||||
@@ -1,4 +1,4 @@
|
||||
"""抓取路由:ラクマ(fril.jp)的搜索、商品详情与卖家
|
||||
"""抓取路由:ラクマ(fril.jp)的搜索、分类、商品详情与卖家
|
||||
|
||||
单独挂在 /api/rakuma 前缀下,不与乐天市场的接口合并:两站的筛选参数体系
|
||||
差异很大(乐天有 genre_id / 成色 / SuperDEAL,ラクマ 有 category_id /
|
||||
@@ -6,10 +6,11 @@ brand_id / 匿名配送 / 鉴定服务),合并会让大半字段对另一站
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.dependencies import get_container, require_bearer_token
|
||||
from app.core.container import ServiceContainer
|
||||
from app.models.scrape import (
|
||||
ApiResponse,
|
||||
from app.shared.api import ApiResponse, get_container, require_bearer_token
|
||||
from app.scraping.container import ScrapingContainer
|
||||
from app.scraping.models.scrape import (
|
||||
RakumaCategoryData,
|
||||
RakumaCategoryRequest,
|
||||
RakumaItemDetailData,
|
||||
RakumaItemDetailRequest,
|
||||
RakumaSearchRequest,
|
||||
@@ -30,7 +31,7 @@ router = APIRouter(prefix="/api/rakuma", tags=["rakuma"])
|
||||
)
|
||||
async def search(
|
||||
payload: RakumaSearchRequest,
|
||||
container: ServiceContainer = Depends(get_container),
|
||||
container: ScrapingContainer = Depends(get_container),
|
||||
) -> ApiResponse[RakumaSearchResultData]:
|
||||
"""搜索 ラクマ 商品列表
|
||||
|
||||
@@ -49,6 +50,34 @@ async def search(
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/categories",
|
||||
response_model=ApiResponse[RakumaCategoryData],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def categories(
|
||||
payload: RakumaCategoryRequest,
|
||||
container: ScrapingContainer = Depends(get_container),
|
||||
) -> ApiResponse[RakumaCategoryData]:
|
||||
"""获取 ラクマ 分类树,用于取得 /api/rakuma/search 需要的 category_id
|
||||
|
||||
不传 category_id 返回 14 个顶层分类;传入后返回该分类的名称、祖先路径与
|
||||
直接子分类。分类共三层,逐层下钻即可定位到叶子分类。
|
||||
|
||||
站点一次请求就返回整棵树,所以把 include_descendants 置为 true 可以直接
|
||||
拿到该分类下的完整子树,不会多花请求。
|
||||
|
||||
站点分类数据里没有商品数(要逐个分类另抓一次页面才有),因此本接口不返回。
|
||||
"""
|
||||
data = await container.rakuma_client.categories(payload)
|
||||
return ApiResponse[RakumaCategoryData](
|
||||
success=True,
|
||||
msg="success",
|
||||
data=data,
|
||||
code=0,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/item_detail",
|
||||
response_model=ApiResponse[RakumaItemDetailData],
|
||||
@@ -56,7 +85,7 @@ async def search(
|
||||
)
|
||||
async def item_detail(
|
||||
payload: RakumaItemDetailRequest,
|
||||
container: ServiceContainer = Depends(get_container),
|
||||
container: ScrapingContainer = Depends(get_container),
|
||||
) -> ApiResponse[RakumaItemDetailData]:
|
||||
"""获取 ラクマ 商品详情
|
||||
|
||||
@@ -82,7 +111,7 @@ async def item_detail(
|
||||
)
|
||||
async def shop_detail(
|
||||
payload: RakumaShopDetailRequest,
|
||||
container: ServiceContainer = Depends(get_container),
|
||||
container: ScrapingContainer = Depends(get_container),
|
||||
) -> ApiResponse[RakumaShopDetailData]:
|
||||
"""获取 ラクマ 卖家(出品者)详情
|
||||
|
||||
@@ -109,7 +138,7 @@ async def shop_detail(
|
||||
)
|
||||
async def shop_items(
|
||||
payload: RakumaShopItemsRequest,
|
||||
container: ServiceContainer = Depends(get_container),
|
||||
container: ScrapingContainer = Depends(get_container),
|
||||
) -> ApiResponse[RakumaShopItemsData]:
|
||||
"""获取 ラクマ 卖家名下的商品列表
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""抓取路由:乐天市场的搜索、分类、商品详情与商家"""
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.dependencies import get_container, require_bearer_token
|
||||
from app.core.container import ServiceContainer
|
||||
from app.models.scrape import (
|
||||
ApiResponse,
|
||||
from app.shared.api import ApiResponse, get_container, require_bearer_token
|
||||
from app.scraping.container import ScrapingContainer
|
||||
from app.scraping.models.scrape import (
|
||||
GenreData,
|
||||
GenreRequest,
|
||||
ItemDetailData,
|
||||
@@ -26,7 +25,7 @@ router = APIRouter(prefix="/api", tags=["scrape"])
|
||||
)
|
||||
async def search(
|
||||
payload: SearchRequest,
|
||||
container: ServiceContainer = Depends(get_container),
|
||||
container: ScrapingContainer = Depends(get_container),
|
||||
) -> ApiResponse[SearchResultData]:
|
||||
"""搜索商品列表
|
||||
|
||||
@@ -52,7 +51,7 @@ async def search(
|
||||
)
|
||||
async def genres(
|
||||
payload: GenreRequest,
|
||||
container: ServiceContainer = Depends(get_container),
|
||||
container: ScrapingContainer = Depends(get_container),
|
||||
) -> ApiResponse[GenreData]:
|
||||
"""获取乐天分类(genre)树,用于取得 /api/search 需要的 genre_id
|
||||
|
||||
@@ -78,7 +77,7 @@ async def genres(
|
||||
)
|
||||
async def item_detail(
|
||||
payload: ItemDetailRequest,
|
||||
container: ServiceContainer = Depends(get_container),
|
||||
container: ScrapingContainer = Depends(get_container),
|
||||
) -> ApiResponse[ItemDetailData]:
|
||||
"""获取商品详情
|
||||
|
||||
@@ -102,7 +101,7 @@ async def item_detail(
|
||||
)
|
||||
async def shop_detail(
|
||||
payload: ShopDetailRequest,
|
||||
container: ServiceContainer = Depends(get_container),
|
||||
container: ScrapingContainer = Depends(get_container),
|
||||
) -> ApiResponse[ShopDetailData]:
|
||||
"""获取乐天商家(店铺)详情
|
||||
|
||||
@@ -128,7 +127,7 @@ async def shop_detail(
|
||||
)
|
||||
async def shop_items(
|
||||
payload: ShopItemsRequest,
|
||||
container: ServiceContainer = Depends(get_container),
|
||||
container: ScrapingContainer = Depends(get_container),
|
||||
) -> ApiResponse[SearchResultData]:
|
||||
"""获取乐天商家名下的商品列表
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""抓取服务容器:集中管理抓取侧服务实例,用于依赖注入"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.scraping.services.browser_fallback import BrowserFallback
|
||||
from app.scraping.services.rakuma_client import RakumaClient
|
||||
from app.scraping.services.rakuma_session import RakumaSession
|
||||
from app.scraping.services.rakuten_client import RakutenClient
|
||||
from app.scraping.services.site_session import SiteSession
|
||||
from app.shared.config import Settings
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ScrapingContainer:
|
||||
"""抓取服务容器,持有抓取链路的全部服务实例
|
||||
|
||||
通过 FastAPI 的 app.state.container 在请求间共享,
|
||||
各路由通过依赖注入获取容器中的服务。
|
||||
|
||||
两个站点各自持有独立的会话与客户端:乐天需要 Akamai cookie 预热与双指纹
|
||||
通道,ラクマ 不需要,抓取前提不同不便合并。
|
||||
|
||||
这里**没有登录态**:抓取全程匿名,账号相关的一切在交易服务
|
||||
(app/trading/)里。这也是抓取服务可以随意多开实例的前提——一旦把
|
||||
登录态放回来,多实例就会出现同一账号被多个进程并发操作的问题。
|
||||
"""
|
||||
|
||||
settings: Settings
|
||||
browser_fallback: BrowserFallback
|
||||
site_session: SiteSession
|
||||
rakuten_client: RakutenClient
|
||||
rakuma_session: RakumaSession
|
||||
rakuma_client: RakumaClient
|
||||
@@ -15,6 +15,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
from app.shared import headers
|
||||
|
||||
# ---- 站点入口 ----
|
||||
HOME_URL: Final = "https://fril.jp/"
|
||||
SEARCH_BASE_URL: Final = "https://fril.jp/s"
|
||||
@@ -23,6 +25,12 @@ SHOP_BASE_URL: Final = "https://fril.jp/shop/"
|
||||
CATEGORY_BASE_URL: Final = "https://fril.jp/category/"
|
||||
BRAND_BASE_URL: Final = "https://fril.jp/brand/"
|
||||
|
||||
# 分类一览页。它是 Next.js App Router 页面,服务端把整棵分类树写进 RSC flight
|
||||
# payload(`self.__next_f.push`)。实测这份数据与 `?category_id=` 无关:传任意
|
||||
# 合法分类、或完全不传,返回的 categoryList 都是同一份全量树(1686 条),
|
||||
# 因此取分类只需一次请求,不像乐天 genre 那样必须逐层下钻。
|
||||
CATEGORY_LIST_URL: Final = "https://fril.jp/category"
|
||||
|
||||
SEARCH_HOST: Final = "fril.jp"
|
||||
ITEM_HOST: Final = "item.fril.jp"
|
||||
|
||||
@@ -39,28 +47,12 @@ USER_AGENT: Final = (
|
||||
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
ACCEPT_LANGUAGE: Final = "ja,en-US;q=0.9,en;q=0.8"
|
||||
ACCEPT_LANGUAGE: Final = headers.ACCEPT_LANGUAGE
|
||||
|
||||
|
||||
def default_headers() -> dict[str, str]:
|
||||
"""构造一套完整的浏览器导航请求头"""
|
||||
return {
|
||||
"User-Agent": USER_AGENT,
|
||||
"Accept": (
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,"
|
||||
"image/avif,image/webp,image/apng,*/*;q=0.8,"
|
||||
"application/signed-exchange;v=b3;q=0.7"
|
||||
),
|
||||
"Accept-Language": ACCEPT_LANGUAGE,
|
||||
"sec-ch-ua": '"Chromium";v="131", "Not_A Brand";v="24", "Google Chrome";v="131"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"Windows"',
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
}
|
||||
return headers.navigation_headers(USER_AGENT, mobile=False)
|
||||
|
||||
|
||||
# ---- 排序(搜索页 `sort=` + `order=` 两个参数)----
|
||||
@@ -10,6 +10,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
from app.shared import headers
|
||||
|
||||
# ---- 站点入口 ----
|
||||
HOME_URL: Final = "https://www.rakuten.co.jp/"
|
||||
SEARCH_BASE_URL: Final = "https://search.rakuten.co.jp/search/mall/"
|
||||
@@ -47,30 +49,14 @@ SP_USER_AGENT: Final = (
|
||||
"(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
|
||||
)
|
||||
|
||||
ACCEPT_LANGUAGE: Final = "ja,en-US;q=0.9,en;q=0.8"
|
||||
ACCEPT_LANGUAGE: Final = headers.ACCEPT_LANGUAGE
|
||||
|
||||
|
||||
# 乐天前置 Akamai Bot Manager。请求头不完整时不会直接封禁,而是把响应
|
||||
# 拖到 ~11s(实测与响应体大小无关,22 字节的响应同样耗时 11s);补齐
|
||||
# 下列头并复用 Akamai 下发的 cookie 后,稳定在 ~0.6-0.9s。
|
||||
def default_headers(*, mobile: bool) -> dict[str, str]:
|
||||
"""构造一套完整的浏览器导航请求头。"""
|
||||
return {
|
||||
"User-Agent": SP_USER_AGENT if mobile else PC_USER_AGENT,
|
||||
"Accept": (
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,"
|
||||
"image/avif,image/webp,image/apng,*/*;q=0.8,"
|
||||
"application/signed-exchange;v=b3;q=0.7"
|
||||
),
|
||||
"Accept-Language": ACCEPT_LANGUAGE,
|
||||
"sec-ch-ua": '"Chromium";v="131", "Not_A Brand";v="24", "Google Chrome";v="131"',
|
||||
"sec-ch-ua-mobile": "?1" if mobile else "?0",
|
||||
"sec-ch-ua-platform": '"iOS"' if mobile else '"Windows"',
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
}
|
||||
return headers.navigation_headers(
|
||||
SP_USER_AGENT if mobile else PC_USER_AGENT, mobile=mobile
|
||||
)
|
||||
|
||||
|
||||
# Akamai Bot Manager 下发的 cookie:判断会话是否已预热完成的依据
|
||||
@@ -0,0 +1,96 @@
|
||||
"""抓取服务入口:FastAPI 应用创建与生命周期管理
|
||||
|
||||
职责:
|
||||
- 构建抓取服务容器(依赖注入)
|
||||
- 管理应用生命周期(启动/关闭抓取会话与兜底浏览器)
|
||||
- 注册抓取路由和全局异常处理器
|
||||
|
||||
这个进程全程匿名、无状态,可按需要多开实例。需要账号登录态的加购、下单与
|
||||
订单监控在交易服务里(`python -m app.trading.main`),两者独立部署。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.scraping.api.routes.health import router as health_router
|
||||
from app.scraping.api.routes.rakuma import router as rakuma_router
|
||||
from app.scraping.api.routes.scrape import router as scrape_router
|
||||
from app.scraping.container import ScrapingContainer
|
||||
from app.scraping.services.browser_fallback import BrowserFallback
|
||||
from app.scraping.services.rakuma_client import RakumaClient
|
||||
from app.scraping.services.rakuma_session import RakumaSession
|
||||
from app.scraping.services.rakuten_client import RakutenClient
|
||||
from app.scraping.services.site_session import SiteSession
|
||||
from app.shared.api import register_exception_handlers
|
||||
from app.shared.config import get_settings
|
||||
from app.shared.logging_setup import configure_logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_container() -> ScrapingContainer:
|
||||
"""构建抓取服务容器,组装所有依赖"""
|
||||
settings = get_settings()
|
||||
browser_fallback = BrowserFallback(settings)
|
||||
site_session = SiteSession(settings, browser_fallback)
|
||||
rakuten_client = RakutenClient(settings, site_session)
|
||||
rakuma_session = RakumaSession(settings)
|
||||
rakuma_client = RakumaClient(settings, rakuma_session)
|
||||
return ScrapingContainer(
|
||||
settings=settings,
|
||||
browser_fallback=browser_fallback,
|
||||
site_session=site_session,
|
||||
rakuten_client=rakuten_client,
|
||||
rakuma_session=rakuma_session,
|
||||
rakuma_client=rakuma_client,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理:启动时初始化各服务,关闭时释放资源"""
|
||||
container = build_container()
|
||||
app.state.container = container
|
||||
|
||||
configure_logging(container.settings)
|
||||
logger.info("抓取服务启动:%s:%s", container.settings.app_host, container.settings.app_port)
|
||||
logger.info("日志级别:%s", container.settings.log_level)
|
||||
logger.info("当前环境:%s", container.settings.app_env)
|
||||
await container.site_session.start()
|
||||
await container.rakuma_session.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await container.rakuma_session.close()
|
||||
await container.site_session.close()
|
||||
await container.browser_fallback.close()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""创建 FastAPI 应用实例,注册路由和异常处理器"""
|
||||
app = FastAPI(title="Rakuten Scraper Service", lifespan=lifespan)
|
||||
app.include_router(health_router)
|
||||
app.include_router(scrape_router)
|
||||
app.include_router(rakuma_router)
|
||||
register_exception_handlers(app)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
settings = get_settings()
|
||||
configure_logging(settings)
|
||||
uvicorn.run(
|
||||
"app.scraping.main:app",
|
||||
host=settings.app_host,
|
||||
port=settings.app_port,
|
||||
log_config=None,
|
||||
timeout_keep_alive=120,
|
||||
)
|
||||
@@ -1,26 +1,18 @@
|
||||
"""API 数据模型:请求体和响应体定义
|
||||
"""抓取侧 API 数据模型:请求体和响应体定义
|
||||
|
||||
字段命名贴合乐天站点自身的语义(item_code / shop_code / genre_id / sku 等),
|
||||
不做跨站点的字段名归一,避免解析层与对外契约之间反复翻译。
|
||||
|
||||
响应信封 `ApiResponse` 在 app/shared/api.py,与交易侧共用;登录态、订单等需要
|
||||
账号的模型在 app/trading/models.py,不在这里。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Any, Generic, TypeVar
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl, model_validator
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class ApiResponse(BaseModel, Generic[T]):
|
||||
"""统一 API 响应格式"""
|
||||
|
||||
success: bool
|
||||
msg: str
|
||||
data: T | None = None
|
||||
code: int
|
||||
|
||||
|
||||
class SortOption(StrEnum):
|
||||
"""搜索排序方式,对应搜索页 `s=` 参数"""
|
||||
@@ -433,7 +425,10 @@ class ItemDetailData(BaseModel):
|
||||
|
||||
|
||||
class HealthData(BaseModel):
|
||||
"""健康检查响应数据"""
|
||||
"""抓取服务健康检查响应数据
|
||||
|
||||
这里不含登录态——登录态属于交易服务,查它请打交易服务的 /health。
|
||||
"""
|
||||
|
||||
status: str
|
||||
browser_fallback_enabled: bool
|
||||
@@ -570,6 +565,49 @@ class RakumaShopItemsRequest(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class RakumaCategoryRequest(BaseModel):
|
||||
"""ラクマ 分类查询参数
|
||||
|
||||
不传 category_id 时返回 14 个顶层分类;传入时返回该分类的名称、祖先路径与
|
||||
直接子分类。站点一次请求就返回整棵树,因此 include_descendants 只是换一种
|
||||
组织方式,不会多打请求。
|
||||
"""
|
||||
|
||||
category_id: str | None = None
|
||||
# 返回该分类下的完整子树(不止直接子级)。分类树共三层,
|
||||
# 顶层分类的子树可达上百条。
|
||||
include_descendants: bool = False
|
||||
|
||||
|
||||
class RakumaCategoryNode(BaseModel):
|
||||
"""ラクマ 分类树上的一个节点
|
||||
|
||||
站点只给 id / parentId / name / hasChild 四个字段,没有商品数——
|
||||
商品数要逐个分类去抓 `/category/{id}` 页面,成本过高,本接口不提供。
|
||||
"""
|
||||
|
||||
category_id: str = ""
|
||||
name: str = ""
|
||||
parent_id: str = "" # "0" 表示顶层分类
|
||||
is_leaf: bool = False # 叶子分类,没有下级
|
||||
url: str = "" # 分类页地址
|
||||
# include_descendants=true 时填充下级分类,否则始终为空
|
||||
children: list[RakumaCategoryNode] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RakumaCategoryData(BaseModel):
|
||||
"""ラクマ 分类查询结果"""
|
||||
|
||||
category_id: str = "" # 空串表示顶层
|
||||
name: str = ""
|
||||
full_name: str = "" # 从顶层拼到自身的路径名,如「エンタメ/ホビー / ゲームソフト/ゲーム機本体 / 家庭用ゲームソフト」
|
||||
is_leaf: bool = False
|
||||
url: str = ""
|
||||
total_count: int = 0 # 站点分类树的节点总数,用于确认取到的是全量树
|
||||
ancestors: list[RakumaCategoryNode] = Field(default_factory=list) # 从顶层到父级,不含自身
|
||||
children: list[RakumaCategoryNode] = Field(default_factory=list) # 直接子分类;include_descendants=true 时带子树
|
||||
|
||||
|
||||
class RakumaSeller(BaseModel):
|
||||
"""ラクマ 卖家(出品者)摘要"""
|
||||
|
||||
@@ -10,10 +10,10 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.core import site
|
||||
from app.core.errors import ScrapeParseError
|
||||
from app.models.scrape import GenreData, GenreNode
|
||||
from app.utils.coerce import as_dict, as_int, as_list, as_str
|
||||
from app.scraping.core import site
|
||||
from app.shared.errors import ScrapeParseError
|
||||
from app.scraping.models.scrape import GenreData, GenreNode
|
||||
from app.scraping.utils.coerce import as_dict, as_int, as_list, as_str
|
||||
|
||||
# 站点用 id=0 表示分类树的虚拟根,它不是一个真实分类
|
||||
ROOT_GENRE_ID = 0
|
||||
@@ -10,8 +10,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.core.errors import ScrapeParseError
|
||||
from app.models.scrape import (
|
||||
from app.shared.errors import ScrapeParseError
|
||||
from app.scraping.models.scrape import (
|
||||
Breadcrumb,
|
||||
ItemDetailData,
|
||||
PurchaseInfo,
|
||||
@@ -26,7 +26,7 @@ from app.models.scrape import (
|
||||
SkuInfo,
|
||||
SkuVariant,
|
||||
)
|
||||
from app.utils.coerce import as_dict, as_float, as_int, as_list, as_str
|
||||
from app.scraping.utils.coerce import as_dict, as_float, as_int, as_list, as_str
|
||||
|
||||
# purchase.sellType 下表示「可正常购买」的状态值
|
||||
_PURCHASABLE_CONDITION = "enabled"
|
||||
@@ -0,0 +1,9 @@
|
||||
"""ラクマ(fril.jp)页面解析器
|
||||
|
||||
站点是服务端渲染的 HTML,没有内联状态 JSON,因此各模块都走 DOM 解析:
|
||||
- base — 埋点属性与文本取值的公共工具
|
||||
- search — 搜索页(商品卡片解析同时被店铺页复用)
|
||||
- item — 商品详情页
|
||||
- shop — 店铺页与评价页
|
||||
- category — 分类一览页(唯一的例外:Next.js 页面,数据在 RSC flight payload 里)
|
||||
"""
|
||||
@@ -0,0 +1,155 @@
|
||||
"""ラクマ 分类一览页 → RakumaCategoryData
|
||||
|
||||
站点其余页面都是 Rails 服务端渲染的老模板,只有 `/category` 换成了 Next.js
|
||||
App Router:整棵分类树写在 RSC flight payload 里,一段段挂在
|
||||
`self.__next_f.push([1, "<字符串>"])` 上。把这些字符串按顺序拼回去,就能拿到
|
||||
`"categoryList":[{"id":...,"parentId":...,"name":...,"hasChild":...}, ...]`。
|
||||
|
||||
这份 categoryList 是**全量扁平树**(实测 1686 条:14 个顶层 + 169 个二级 +
|
||||
1503 个三级),且与 URL 上的 `?category_id=` 无关——传任意分类或完全不传,
|
||||
内容都一样。所以一次请求即可满足任意层级的查询,不需要像乐天 genre 那样
|
||||
逐层下钻。
|
||||
|
||||
站点只给 id / parentId / name / hasChild 四个字段,没有商品数:商品数只在
|
||||
`/category/{id}` 列表页的埋点属性上,要逐个分类多打一次请求,这里不做。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.shared.errors import ItemNotFoundError, ScrapeParseError
|
||||
from app.scraping.models.scrape import RakumaCategoryData, RakumaCategoryNode
|
||||
from app.scraping.utils.rakuma_urls import build_category_url
|
||||
|
||||
# flight payload 的分片:self.__next_f.push([1,"...JSON 字符串字面量..."])
|
||||
_FLIGHT_CHUNK_RE = re.compile(r'self\.__next_f\.push\(\[1,("(?:[^"\\]|\\.)*")\]\)')
|
||||
# 站点用 parentId=0 表示顶层分类,0 本身不是一个真实分类
|
||||
ROOT_PARENT_ID = 0
|
||||
|
||||
|
||||
def _flight_payload(html: str) -> str:
|
||||
"""把 RSC flight payload 的所有分片按顺序拼成一整段文本
|
||||
|
||||
每个分片是一个 JS 字符串字面量,转义规则与 JSON 一致,因此直接用
|
||||
json.loads 解码;单个分片解不开时跳过它而不是放弃整页——分类数据可能
|
||||
落在其他分片上。
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for match in _FLIGHT_CHUNK_RE.finditer(html):
|
||||
try:
|
||||
parts.append(json.loads(match.group(1)))
|
||||
except ValueError:
|
||||
continue
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _raw_categories(html: str) -> list[dict[str, Any]]:
|
||||
"""从页面里取出扁平分类列表
|
||||
|
||||
Raises:
|
||||
ScrapeParseError: 页面里没有 categoryList,或它不是非空数组
|
||||
"""
|
||||
payload = _flight_payload(html)
|
||||
marker = payload.find('"categoryList":')
|
||||
if marker < 0:
|
||||
raise ScrapeParseError(
|
||||
"分类页中缺少 categoryList 数据;站点可能改版或返回了非预期页面"
|
||||
)
|
||||
|
||||
start = payload.find("[", marker)
|
||||
if start < 0:
|
||||
raise ScrapeParseError("分类页的 categoryList 不是数组")
|
||||
|
||||
try:
|
||||
raw, _ = json.JSONDecoder().raw_decode(payload[start:])
|
||||
except ValueError as exc:
|
||||
raise ScrapeParseError(f"分类页的 categoryList 解析失败:{exc}") from exc
|
||||
|
||||
items = [item for item in raw if isinstance(item, dict) and item.get("id") is not None]
|
||||
if not items:
|
||||
raise ScrapeParseError("分类页的 categoryList 为空")
|
||||
return items
|
||||
|
||||
|
||||
def _to_node(raw: dict[str, Any]) -> RakumaCategoryNode:
|
||||
category_id = str(raw.get("id"))
|
||||
return RakumaCategoryNode(
|
||||
category_id=category_id,
|
||||
name=str(raw.get("name") or ""),
|
||||
parent_id=str(raw.get("parentId") if raw.get("parentId") is not None else ROOT_PARENT_ID),
|
||||
is_leaf=not bool(raw.get("hasChild")),
|
||||
url=build_category_url(category_id),
|
||||
)
|
||||
|
||||
|
||||
def _build_subtree(
|
||||
node: RakumaCategoryNode,
|
||||
children_of: dict[str, list[RakumaCategoryNode]],
|
||||
) -> RakumaCategoryNode:
|
||||
"""递归把下级分类挂到 children 上
|
||||
|
||||
分类树只有三层,递归深度可控;节点在 categoryList 里 id 唯一,
|
||||
不会出现自环。
|
||||
"""
|
||||
return node.model_copy(
|
||||
update={
|
||||
"children": [
|
||||
_build_subtree(child, children_of)
|
||||
for child in children_of.get(node.category_id, [])
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def parse_categories(
|
||||
html: str, *, category_id: str | None, include_descendants: bool
|
||||
) -> RakumaCategoryData:
|
||||
"""解析分类树
|
||||
|
||||
Args:
|
||||
category_id: 目标分类;None 表示取顶层分类列表
|
||||
include_descendants: children 里带上完整子树而非只有直接子级
|
||||
|
||||
Raises:
|
||||
ScrapeParseError: 页面里没有分类树
|
||||
ItemNotFoundError: 目标分类不存在于站点分类树中
|
||||
"""
|
||||
raw_items = _raw_categories(html)
|
||||
nodes = {str(raw["id"]): _to_node(raw) for raw in raw_items}
|
||||
|
||||
children_of: dict[str, list[RakumaCategoryNode]] = {}
|
||||
for node in nodes.values():
|
||||
children_of.setdefault(node.parent_id, []).append(node)
|
||||
|
||||
def resolve(node: RakumaCategoryNode) -> RakumaCategoryNode:
|
||||
return _build_subtree(node, children_of) if include_descendants else node
|
||||
|
||||
root_children = children_of.get(str(ROOT_PARENT_ID), [])
|
||||
if category_id is None:
|
||||
return RakumaCategoryData(
|
||||
total_count=len(nodes),
|
||||
children=[resolve(node) for node in root_children],
|
||||
)
|
||||
|
||||
target = nodes.get(category_id.strip())
|
||||
if target is None:
|
||||
raise ItemNotFoundError(f"分类树中未找到分类 {category_id}")
|
||||
|
||||
ancestors: list[RakumaCategoryNode] = []
|
||||
parent = nodes.get(target.parent_id)
|
||||
while parent is not None:
|
||||
ancestors.insert(0, parent)
|
||||
parent = nodes.get(parent.parent_id)
|
||||
|
||||
return RakumaCategoryData(
|
||||
category_id=target.category_id,
|
||||
name=target.name,
|
||||
full_name=" / ".join([node.name for node in ancestors] + [target.name]),
|
||||
is_leaf=target.is_leaf,
|
||||
url=target.url,
|
||||
total_count=len(nodes),
|
||||
ancestors=ancestors,
|
||||
children=[resolve(node) for node in children_of.get(target.category_id, [])],
|
||||
)
|
||||
@@ -17,10 +17,10 @@ from typing import Any
|
||||
|
||||
from selectolax.parser import HTMLParser
|
||||
|
||||
from app.core import rakuma_site as site
|
||||
from app.core.errors import ScrapeParseError
|
||||
from app.models.scrape import Breadcrumb, RakumaItemDetailData, RakumaSeller
|
||||
from app.parsers.rakuma.base import (
|
||||
from app.scraping.core import rakuma_site as site
|
||||
from app.shared.errors import ScrapeParseError
|
||||
from app.scraping.models.scrape import Breadcrumb, RakumaItemDetailData, RakumaSeller
|
||||
from app.scraping.parsers.rakuma.base import (
|
||||
attr,
|
||||
find_item_payload,
|
||||
image_url,
|
||||
@@ -29,7 +29,7 @@ from app.parsers.rakuma.base import (
|
||||
parse_int,
|
||||
rat_params,
|
||||
)
|
||||
from app.utils.rakuma_urls import split_shop_url
|
||||
from app.scraping.utils.rakuma_urls import split_shop_url
|
||||
|
||||
_LD_JSON_RE = re.compile(
|
||||
r'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>', re.S
|
||||
@@ -13,10 +13,10 @@ import re
|
||||
|
||||
from selectolax.parser import HTMLParser, Node
|
||||
|
||||
from app.core import rakuma_site as site
|
||||
from app.core.errors import ScrapeParseError
|
||||
from app.models.scrape import RakumaSearchItem, RakumaSearchResultData
|
||||
from app.parsers.rakuma.base import (
|
||||
from app.scraping.core import rakuma_site as site
|
||||
from app.shared.errors import ScrapeParseError
|
||||
from app.scraping.models.scrape import RakumaSearchItem, RakumaSearchResultData
|
||||
from app.scraping.parsers.rakuma.base import (
|
||||
attr,
|
||||
event_payload,
|
||||
image_url,
|
||||
@@ -24,7 +24,7 @@ from app.parsers.rakuma.base import (
|
||||
parse_int,
|
||||
parse_total_count,
|
||||
)
|
||||
from app.utils.rakuma_urls import item_id_from_url
|
||||
from app.scraping.utils.rakuma_urls import item_id_from_url
|
||||
|
||||
# 埋点属性里的精确命中总数
|
||||
_TOTAL_RESULTS_RE = re.compile(r'data-rat-cp-totalresults="(\d+)"')
|
||||
@@ -13,15 +13,15 @@ import re
|
||||
|
||||
from selectolax.parser import HTMLParser
|
||||
|
||||
from app.core import rakuma_site as site
|
||||
from app.core.errors import ScrapeParseError
|
||||
from app.models.scrape import (
|
||||
from app.scraping.core import rakuma_site as site
|
||||
from app.shared.errors import ScrapeParseError
|
||||
from app.scraping.models.scrape import (
|
||||
RakumaRatingBreakdown,
|
||||
RakumaReview,
|
||||
RakumaShopDetailData,
|
||||
RakumaShopItemsData,
|
||||
)
|
||||
from app.parsers.rakuma.base import (
|
||||
from app.scraping.parsers.rakuma.base import (
|
||||
attr,
|
||||
event_payload,
|
||||
image_url,
|
||||
@@ -30,7 +30,7 @@ from app.parsers.rakuma.base import (
|
||||
parse_int,
|
||||
parse_total_count,
|
||||
)
|
||||
from app.parsers.rakuma.search import parse_item_cards
|
||||
from app.scraping.parsers.rakuma.search import parse_item_cards
|
||||
|
||||
# 评价条目标题左侧的图标 class → 评价档位
|
||||
_RATING_ICONS = {
|
||||
@@ -11,11 +11,11 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.core import site
|
||||
from app.core.errors import ScrapeParseError
|
||||
from app.models.scrape import ReviewSummary, SearchItem, SearchResultData, ShopSummary
|
||||
from app.utils.coerce import as_dict, as_float, as_int, as_list, as_str
|
||||
from app.utils.urls import item_url_parts
|
||||
from app.scraping.core import site
|
||||
from app.shared.errors import ScrapeParseError
|
||||
from app.scraping.models.scrape import ReviewSummary, SearchItem, SearchResultData, ShopSummary
|
||||
from app.scraping.utils.coerce import as_dict, as_float, as_int, as_list, as_str
|
||||
from app.scraping.utils.urls import item_url_parts
|
||||
|
||||
|
||||
def parse_search_item(raw: dict[str, Any]) -> SearchItem:
|
||||
@@ -12,10 +12,10 @@ import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.core import site
|
||||
from app.core.errors import ScrapeParseError
|
||||
from app.models.scrape import ShopDetailData
|
||||
from app.utils.coerce import as_dict, as_float, as_int, as_list, as_str
|
||||
from app.scraping.core import site
|
||||
from app.shared.errors import ScrapeParseError
|
||||
from app.scraping.models.scrape import ShopDetailData
|
||||
from app.scraping.utils.coerce import as_dict, as_float, as_int, as_list, as_str
|
||||
|
||||
_LD_JSON_RE = re.compile(
|
||||
r'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>', re.S
|
||||
@@ -13,7 +13,7 @@ import re
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from app.core.errors import ScrapeParseError
|
||||
from app.shared.errors import ScrapeParseError
|
||||
|
||||
# 页面必须包含的服务端渲染数据标记;缺失说明拿到的不是正常页面
|
||||
STATE_MARKER = "window.__INITIAL_STATE__"
|
||||
@@ -8,12 +8,12 @@ from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from app.core import site
|
||||
from app.core.errors import OffIchibaRedirectError, ScrapeParseError
|
||||
from app.models.scrape import ItemDetailData
|
||||
from app.parsers.state import PageValidator, require_state_marker
|
||||
from app.parsers.subsites import biccamera, books, brandavenue
|
||||
from app.parsers.subsites.base import SubsitePage, SubsiteParser
|
||||
from app.scraping.core import site
|
||||
from app.shared.errors import OffIchibaRedirectError, ScrapeParseError
|
||||
from app.scraping.models.scrape import ItemDetailData
|
||||
from app.scraping.parsers.state import PageValidator, require_state_marker
|
||||
from app.scraping.parsers.subsites import biccamera, books, brandavenue
|
||||
from app.scraping.parsers.subsites.base import SubsitePage, SubsiteParser
|
||||
|
||||
SUBSITE_PARSERS: dict[str, SubsiteParser] = {
|
||||
module.HOST: SubsiteParser(
|
||||
@@ -10,7 +10,7 @@ import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.models.scrape import ItemDetailData
|
||||
from app.scraping.models.scrape import ItemDetailData
|
||||
|
||||
# 售罄判定关键词:日文页面上表示不可购买的常见措辞
|
||||
SOLD_OUT_MARKERS = ("在庫なし", "在庫切れ", "品切れ", "入荷未定", "販売終了", "取扱終了")
|
||||
@@ -11,8 +11,8 @@ from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
|
||||
from app.core.errors import ScrapeParseError
|
||||
from app.models.scrape import (
|
||||
from app.shared.errors import ScrapeParseError
|
||||
from app.scraping.models.scrape import (
|
||||
Breadcrumb,
|
||||
ItemDetailData,
|
||||
PurchaseInfo,
|
||||
@@ -20,8 +20,8 @@ from app.models.scrape import (
|
||||
ShopSummary,
|
||||
SkuInfo,
|
||||
)
|
||||
from app.parsers.subsites.base import SubsitePage
|
||||
from app.utils.coerce import as_dict, as_int, as_list, as_str
|
||||
from app.scraping.parsers.subsites.base import SubsitePage
|
||||
from app.scraping.utils.coerce import as_dict, as_int, as_list, as_str
|
||||
|
||||
HOST = "biccamera.rakuten.co.jp"
|
||||
SOURCE = "biccamera"
|
||||
@@ -13,8 +13,8 @@ import re
|
||||
|
||||
from selectolax.parser import HTMLParser
|
||||
|
||||
from app.core.errors import ScrapeParseError
|
||||
from app.models.scrape import (
|
||||
from app.shared.errors import ScrapeParseError
|
||||
from app.scraping.models.scrape import (
|
||||
Breadcrumb,
|
||||
ItemDetailData,
|
||||
PurchaseInfo,
|
||||
@@ -24,7 +24,7 @@ from app.models.scrape import (
|
||||
SkuAttribute,
|
||||
SkuInfo,
|
||||
)
|
||||
from app.parsers.subsites.base import SubsitePage, looks_sold_out, parse_price
|
||||
from app.scraping.parsers.subsites.base import SubsitePage, looks_sold_out, parse_price
|
||||
|
||||
HOST = "books.rakuten.co.jp"
|
||||
SOURCE = "books"
|
||||
@@ -14,8 +14,8 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from app.core.errors import ScrapeParseError
|
||||
from app.models.scrape import (
|
||||
from app.shared.errors import ScrapeParseError
|
||||
from app.scraping.models.scrape import (
|
||||
Breadcrumb,
|
||||
ItemDetailData,
|
||||
PurchaseInfo,
|
||||
@@ -26,9 +26,9 @@ from app.models.scrape import (
|
||||
SkuInfo,
|
||||
SkuVariant,
|
||||
)
|
||||
from app.parsers.state import extract_initial_state
|
||||
from app.parsers.subsites.base import SubsitePage, parse_price
|
||||
from app.utils.coerce import as_dict, as_int, as_list, as_str
|
||||
from app.scraping.parsers.state import extract_initial_state
|
||||
from app.scraping.parsers.subsites.base import SubsitePage, parse_price
|
||||
from app.scraping.utils.coerce import as_dict, as_int, as_list, as_str
|
||||
|
||||
HOST = "brandavenue.rakuten.co.jp"
|
||||
SOURCE = "brandavenue"
|
||||
@@ -15,8 +15,8 @@ import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core import site
|
||||
from app.shared.config import Settings
|
||||
from app.scraping.core import site
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
"""ラクマ 抓取客户端:把请求参数翻译成站点 URL,抓取后解析为结构化数据
|
||||
|
||||
四个接口都走同一条 HTTP 通道(ラクマ 无 Akamai 限速,不需要分指纹通道):
|
||||
搜索、商品详情、卖家详情、卖家商品列表。
|
||||
五个接口都走同一条 HTTP 通道(ラクマ 无 Akamai 限速,不需要分指纹通道):
|
||||
搜索、分类、商品详情、卖家详情、卖家商品列表。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.models.scrape import (
|
||||
from app.scraping.core import rakuma_site as site
|
||||
from app.shared.config import Settings
|
||||
from app.scraping.models.scrape import (
|
||||
RakumaCategoryData,
|
||||
RakumaCategoryRequest,
|
||||
RakumaItemDetailData,
|
||||
RakumaItemDetailRequest,
|
||||
RakumaSearchRequest,
|
||||
@@ -19,11 +22,12 @@ from app.models.scrape import (
|
||||
RakumaShopItemsData,
|
||||
RakumaShopItemsRequest,
|
||||
)
|
||||
from app.parsers.rakuma.item import parse_item_detail
|
||||
from app.parsers.rakuma.search import parse_search
|
||||
from app.parsers.rakuma.shop import parse_shop_detail, parse_shop_items
|
||||
from app.services.rakuma_session import RakumaSession
|
||||
from app.utils.rakuma_urls import (
|
||||
from app.scraping.parsers.rakuma.category import parse_categories
|
||||
from app.scraping.parsers.rakuma.item import parse_item_detail
|
||||
from app.scraping.parsers.rakuma.search import parse_search
|
||||
from app.scraping.parsers.rakuma.shop import parse_shop_detail, parse_shop_items
|
||||
from app.scraping.services.rakuma_session import RakumaSession
|
||||
from app.scraping.utils.rakuma_urls import (
|
||||
build_item_url,
|
||||
build_search_url,
|
||||
build_shop_url,
|
||||
@@ -63,6 +67,27 @@ class RakumaClient:
|
||||
)
|
||||
return result
|
||||
|
||||
async def categories(self, payload: RakumaCategoryRequest) -> RakumaCategoryData:
|
||||
"""抓取分类树
|
||||
|
||||
分类一览页一次就返回整棵树(与 URL 上的 category_id 无关),因此不论
|
||||
查哪一层都只打一次请求,页面地址也固定不带参数。
|
||||
"""
|
||||
url = site.CATEGORY_LIST_URL
|
||||
|
||||
logger.info("抓取 ラクマ 分类页:category_id=%s", payload.category_id)
|
||||
html = await self._session.fetch_html(url)
|
||||
data = parse_categories(
|
||||
html,
|
||||
category_id=payload.category_id,
|
||||
include_descendants=payload.include_descendants,
|
||||
)
|
||||
logger.info(
|
||||
"ラクマ 分类完成:category_id=%s name=%s children=%s tree=%s",
|
||||
data.category_id, data.name, len(data.children), data.total_count,
|
||||
)
|
||||
return data
|
||||
|
||||
async def item_detail(self, payload: RakumaItemDetailRequest) -> RakumaItemDetailData:
|
||||
"""抓取商品详情"""
|
||||
if payload.item_url is not None:
|
||||
@@ -19,9 +19,9 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core import rakuma_site as site
|
||||
from app.core.config import Settings
|
||||
from app.core.errors import (
|
||||
from app.scraping.core import rakuma_site as site
|
||||
from app.shared.config import Settings
|
||||
from app.shared.errors import (
|
||||
ItemNotFoundError,
|
||||
ResourceBusyError,
|
||||
UpstreamBlockedError,
|
||||
@@ -8,10 +8,10 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.core import site
|
||||
from app.core.config import Settings
|
||||
from app.core.errors import ScrapeParseError
|
||||
from app.models.scrape import (
|
||||
from app.scraping.core import site
|
||||
from app.shared.config import Settings
|
||||
from app.shared.errors import ScrapeParseError
|
||||
from app.scraping.models.scrape import (
|
||||
GenreData,
|
||||
GenreRequest,
|
||||
ItemDetailData,
|
||||
@@ -22,19 +22,19 @@ from app.models.scrape import (
|
||||
ShopDetailRequest,
|
||||
ShopItemsRequest,
|
||||
)
|
||||
from app.parsers.genre import parse_genres
|
||||
from app.parsers.item import parse_item_detail
|
||||
from app.parsers.search import parse_search
|
||||
from app.parsers.shop import parse_shop_detail
|
||||
from app.parsers.state import extract_initial_state
|
||||
from app.parsers.subsites import (
|
||||
from app.scraping.parsers.genre import parse_genres
|
||||
from app.scraping.parsers.item import parse_item_detail
|
||||
from app.scraping.parsers.search import parse_search
|
||||
from app.scraping.parsers.shop import parse_shop_detail
|
||||
from app.scraping.parsers.state import extract_initial_state
|
||||
from app.scraping.parsers.subsites import (
|
||||
SubsitePage,
|
||||
build_item_page_validator,
|
||||
host_of,
|
||||
parse_subsite_item,
|
||||
)
|
||||
from app.services.site_session import SiteSession
|
||||
from app.utils.urls import (
|
||||
from app.scraping.services.site_session import SiteSession
|
||||
from app.scraping.utils.urls import (
|
||||
build_genre_url,
|
||||
build_item_url,
|
||||
build_search_url,
|
||||
@@ -20,16 +20,16 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core import site
|
||||
from app.core.config import Settings
|
||||
from app.core.errors import (
|
||||
from app.scraping.core import site
|
||||
from app.shared.config import Settings
|
||||
from app.shared.errors import (
|
||||
ItemNotFoundError,
|
||||
ResourceBusyError,
|
||||
UpstreamBlockedError,
|
||||
UpstreamRequestError,
|
||||
)
|
||||
from app.parsers.state import PageValidator, require_state_marker
|
||||
from app.services.browser_fallback import BrowserFallback
|
||||
from app.scraping.parsers.state import PageValidator, require_state_marker
|
||||
from app.scraping.services.browser_fallback import BrowserFallback
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,9 +14,9 @@ from __future__ import annotations
|
||||
|
||||
from urllib.parse import quote, urlencode, urlsplit, urlunsplit, parse_qsl
|
||||
|
||||
from app.core import rakuma_site as site
|
||||
from app.core.errors import InvalidRequestError
|
||||
from app.models.scrape import RakumaSearchRequest
|
||||
from app.scraping.core import rakuma_site as site
|
||||
from app.shared.errors import InvalidRequestError
|
||||
from app.scraping.models.scrape import RakumaSearchRequest
|
||||
|
||||
|
||||
def build_search_url(payload: RakumaSearchRequest) -> str:
|
||||
@@ -85,6 +85,16 @@ def normalize_search_url(raw_url: str, page: int) -> str:
|
||||
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment))
|
||||
|
||||
|
||||
def build_category_url(category_id: str) -> str:
|
||||
"""由分类 ID 拼出分类商品列表页 URL(`/category/{id}`)
|
||||
|
||||
这是对外返回的分类页地址;抓取分类树用的是 `/category` 一览页
|
||||
(见 rakuma_site.CATEGORY_LIST_URL),两者不是同一个页面。
|
||||
"""
|
||||
category = quote(str(category_id).strip().strip("/"), safe="")
|
||||
return f"{site.CATEGORY_BASE_URL}{category}" if category else ""
|
||||
|
||||
|
||||
def build_item_url(item_id: str) -> str:
|
||||
"""由商品 hash 拼出商品详情页 URL"""
|
||||
item = quote(item_id.strip().strip("/"), safe="")
|
||||
@@ -11,9 +11,9 @@ from __future__ import annotations
|
||||
|
||||
from urllib.parse import quote, urlencode, urlparse, urlsplit, urlunsplit, parse_qsl
|
||||
|
||||
from app.core import site
|
||||
from app.core.errors import InvalidRequestError
|
||||
from app.models.scrape import SearchRequest
|
||||
from app.scraping.core import site
|
||||
from app.shared.errors import InvalidRequestError
|
||||
from app.scraping.models.scrape import SearchRequest
|
||||
|
||||
# 只按分类检索(无关键词)时,关键词段的占位符
|
||||
_KEYWORD_PLACEHOLDER = "-"
|
||||
@@ -1,37 +1,80 @@
|
||||
"""应用入口:FastAPI 应用创建与生命周期管理
|
||||
"""两个入口共用的 HTTP 层:响应信封、鉴权依赖、异常处理器
|
||||
|
||||
职责:
|
||||
- 构建服务容器(依赖注入)
|
||||
- 管理应用生命周期(启动/关闭抓取会话与兜底浏览器)
|
||||
- 注册路由和全局异常处理器
|
||||
抓取服务与交易服务是两个独立进程(见 README「两个部署单元」),但对外契约必须
|
||||
一致:同一套 `ApiResponse` 信封、同一份错误码表、同一个 Bearer token。共用的部分
|
||||
集中在这里,两侧的差异只体现在各自注册的路由与容器。
|
||||
|
||||
这里刻意不放任何站点或业务知识——`get_container` 不标注具体容器类型,shared 因此
|
||||
不需要认识 `ScrapingContainer` / `TradingContainer`,避免共用层反向依赖两侧。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
import secrets
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import ValidationError
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.api.routes.health import router as health_router
|
||||
from app.api.routes.rakuma import router as rakuma_router
|
||||
from app.api.routes.scrape import router as scrape_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.container import ServiceContainer
|
||||
from app.core.errors import AppError
|
||||
from app.core.logging_setup import configure_logging
|
||||
from app.models.scrape import ApiResponse
|
||||
from app.services.browser_fallback import BrowserFallback
|
||||
from app.services.rakuma_client import RakumaClient
|
||||
from app.services.rakuma_session import RakumaSession
|
||||
from app.services.rakuten_client import RakutenClient
|
||||
from app.services.site_session import SiteSession
|
||||
from app.shared.errors import AppError, AuthenticationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class ApiResponse(BaseModel, Generic[T]):
|
||||
"""统一 API 响应格式"""
|
||||
|
||||
success: bool
|
||||
msg: str
|
||||
data: T | None = None
|
||||
code: int
|
||||
|
||||
|
||||
# ---- 依赖注入 ----
|
||||
|
||||
|
||||
def get_container(request: Request) -> Any:
|
||||
"""从请求中获取服务容器
|
||||
|
||||
返回类型故意留成 Any:抓取侧与交易侧的容器结构不同,由各自路由标注具体类型。
|
||||
"""
|
||||
return request.app.state.container
|
||||
|
||||
|
||||
def require_bearer_token(
|
||||
request: Request,
|
||||
container: Any = Depends(get_container),
|
||||
) -> None:
|
||||
"""Bearer Token 鉴权依赖
|
||||
|
||||
从请求头 Authorization 中提取 Bearer Token,
|
||||
与服务端配置的 token 做安全比较(使用 secrets.compare_digest 防止时序攻击)。
|
||||
"""
|
||||
authorization = request.headers.get("Authorization")
|
||||
if not authorization:
|
||||
logger.warning("鉴权失败:缺少 Authorization 请求头")
|
||||
raise AuthenticationError("Missing Authorization header")
|
||||
|
||||
token = authorization.replace("Bearer ", "", 1).strip()
|
||||
if token == authorization:
|
||||
logger.warning("鉴权失败:Authorization scheme 非 Bearer")
|
||||
raise AuthenticationError("Invalid Authorization scheme")
|
||||
if not token:
|
||||
logger.warning("鉴权失败:Bearer token 为空")
|
||||
raise AuthenticationError("Invalid token")
|
||||
|
||||
if not secrets.compare_digest(token, container.settings.bearer_token):
|
||||
logger.warning("鉴权失败:token 不匹配")
|
||||
raise AuthenticationError("Invalid token")
|
||||
|
||||
|
||||
# ---- 异常处理 ----
|
||||
|
||||
|
||||
def _format_validation_msg(errors: list[dict]) -> str:
|
||||
"""将校验错误整理为便于前端展示的消息。"""
|
||||
@@ -47,50 +90,17 @@ def _format_validation_msg(errors: list[dict]) -> str:
|
||||
return "; ".join(messages)
|
||||
|
||||
|
||||
def build_container() -> ServiceContainer:
|
||||
"""构建服务容器,组装所有依赖"""
|
||||
settings = get_settings()
|
||||
browser_fallback = BrowserFallback(settings)
|
||||
site_session = SiteSession(settings, browser_fallback)
|
||||
rakuten_client = RakutenClient(settings, site_session)
|
||||
rakuma_session = RakumaSession(settings)
|
||||
rakuma_client = RakumaClient(settings, rakuma_session)
|
||||
return ServiceContainer(
|
||||
settings=settings,
|
||||
browser_fallback=browser_fallback,
|
||||
site_session=site_session,
|
||||
rakuten_client=rakuten_client,
|
||||
rakuma_session=rakuma_session,
|
||||
rakuma_client=rakuma_client,
|
||||
)
|
||||
def jsonable_errors(errors: list[dict]) -> list[dict]:
|
||||
"""剔除校验错误里不可 JSON 序列化的 ctx(如原始异常对象)"""
|
||||
return [{key: value for key, value in error.items() if key != "ctx"} for error in errors]
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理:启动时初始化各服务,关闭时释放资源"""
|
||||
container = build_container()
|
||||
app.state.container = container
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
"""给应用挂上全套异常处理器
|
||||
|
||||
configure_logging(container.settings)
|
||||
logger.info("应用启动:%s:%s", container.settings.app_host, container.settings.app_port)
|
||||
logger.info("日志级别:%s", container.settings.log_level)
|
||||
logger.info("当前环境:%s", container.settings.app_env)
|
||||
await container.site_session.start()
|
||||
await container.rakuma_session.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await container.rakuma_session.close()
|
||||
await container.site_session.close()
|
||||
await container.browser_fallback.close()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""创建 FastAPI 应用实例,注册路由和异常处理器"""
|
||||
app = FastAPI(title="Rakuten Scraper Service", lifespan=lifespan)
|
||||
app.include_router(health_router)
|
||||
app.include_router(scrape_router)
|
||||
app.include_router(rakuma_router)
|
||||
两个入口都调用它,保证抓取失败与下单失败返回的错误结构完全一致,
|
||||
上游只需要按 code 分支,不必区分是哪个服务回的。
|
||||
"""
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
async def app_error_handler(_: Request, exc: AppError) -> JSONResponse:
|
||||
@@ -163,27 +173,3 @@ def create_app() -> FastAPI:
|
||||
code=1500,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def jsonable_errors(errors: list[dict]) -> list[dict]:
|
||||
"""剔除校验错误里不可 JSON 序列化的 ctx(如原始异常对象)"""
|
||||
return [{key: value for key, value in error.items() if key != "ctx"} for error in errors]
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
settings = get_settings()
|
||||
configure_logging(settings)
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host=settings.app_host,
|
||||
port=settings.app_port,
|
||||
log_config=None,
|
||||
timeout_keep_alive=120,
|
||||
)
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
配置项统一使用 RAKUTEN_ 前缀,例如 RAKUTEN_APP_PORT=31107。
|
||||
支持 .env 文件自动加载。
|
||||
|
||||
抓取服务与交易服务是两个进程,但共用这一个 Settings 类:两边都要日志、代理、
|
||||
超时与同一个 Bearer token,拆成两份配置只会让部署时多维护一套。下面按
|
||||
「通用 / 仅抓取 / 仅交易」分区标注,各进程只读自己那部分。
|
||||
"""
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
@@ -9,10 +13,12 @@ from typing import Literal
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from app.core import site
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
# 乐天市场首页。这里不 import app.scraping —— shared 不能反向依赖两侧任何一方,
|
||||
# 否则交易服务也会被迫加载整套抓取模块。
|
||||
DEFAULT_HOME_URL = "https://www.rakuten.co.jp/"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用全局配置
|
||||
@@ -28,12 +34,19 @@ class Settings(BaseSettings):
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# ---- 服务基本配置 ----
|
||||
# ---- 服务基本配置(通用)----
|
||||
app_name: str = "Rakuten Scraper Service"
|
||||
app_env: Literal["dev", "prod", "test"] = "dev"
|
||||
|
||||
# 抓取服务监听地址;交易服务用下面的 trading_host / trading_port
|
||||
app_host: str = "0.0.0.0"
|
||||
app_port: int = 31107
|
||||
|
||||
# 交易服务监听地址。两个服务同机部署时端口必须错开;交易服务只能单实例,
|
||||
# 不要在它前面挂多副本负载均衡。
|
||||
trading_host: str = "0.0.0.0"
|
||||
trading_port: int = 31108
|
||||
|
||||
# ---- 日志配置 ----
|
||||
log_level: str = "INFO"
|
||||
log_to_file: bool | None = None # None 表示根据环境自动决定
|
||||
@@ -44,16 +57,16 @@ class Settings(BaseSettings):
|
||||
log_format: str = "{time:YYYY-MM-DD HH:mm:ss} {level} {message}"
|
||||
log_enqueue: bool = True
|
||||
|
||||
# ---- 鉴权配置 ----
|
||||
# ---- 鉴权配置(通用:两个服务共用同一个对外 token)----
|
||||
bearer_token: str = "REPLACE_WITH_TOKEN_32CHARS"
|
||||
|
||||
# ---- HTTP 抓取配置 ----
|
||||
# ---- HTTP 抓取配置(仅抓取服务)----
|
||||
request_timeout_seconds: float = 30.0
|
||||
max_site_concurrency: int = 8 # 对站点的最大并发请求数
|
||||
http_max_attempts: int = 3 # 单次抓取的最大尝试次数(含首次)
|
||||
session_ttl_seconds: float = 1800.0 # Akamai cookie 会话最长复用时长,超时后重新预热
|
||||
|
||||
# ---- 浏览器兜底配置 ----
|
||||
# ---- 浏览器兜底配置(仅抓取服务)----
|
||||
# 纯 HTTP 被 Akamai 拦截时,用 Playwright 打开页面取回 cookie 再回灌给
|
||||
# HTTP 客户端重试。日常流量不会触发;未安装 playwright 时自动降级为不兜底。
|
||||
browser_fallback_enabled: bool = True
|
||||
@@ -62,13 +75,23 @@ class Settings(BaseSettings):
|
||||
browser_launch_timeout_seconds: float = 60.0
|
||||
browser_nav_timeout_seconds: float = 60.0
|
||||
|
||||
# ---- 代理配置(可选,用于日本 IP)----
|
||||
# ---- 代理配置(通用,可选,用于日本 IP)----
|
||||
# 两个服务同机部署时通常各配各的:抓取高频匿名,出口 IP 被限速换掉即可;
|
||||
# 交易带账号,出口 IP 频繁漂移反而会触发风控。
|
||||
proxy_server: str | None = None
|
||||
proxy_username: str | None = None
|
||||
proxy_password: str | None = None
|
||||
|
||||
# ---- 登录态与下单配置(仅交易服务)----
|
||||
# 人工登录一次后落盘的 Playwright storage_state 目录(相对项目根目录)。
|
||||
# 目录里是可直接冒充账号的 cookie,务必不要提交到版本库。
|
||||
auth_state_dir: str = ".auth"
|
||||
# 下单金额上限(日元)。实际应付金额超过该值时拒绝提交,防止解析出错或
|
||||
# 页面改版导致买到远超预期的订单。设为 0 表示不设上限(不建议)。
|
||||
order_max_total_yen: int = 30000
|
||||
|
||||
# ---- 目标站点 ----
|
||||
home_url: str = site.HOME_URL
|
||||
home_url: str = DEFAULT_HOME_URL
|
||||
|
||||
@property
|
||||
def browser_headless_effective(self) -> bool:
|
||||
@@ -111,6 +134,15 @@ class Settings(BaseSettings):
|
||||
credentials = f"{self.proxy_username}:{self.proxy_password or ''}"
|
||||
return f"{scheme}://{credentials}@{rest}"
|
||||
|
||||
@property
|
||||
def auth_state_path(self) -> Path:
|
||||
"""登录态目录的绝对路径,不存在时创建"""
|
||||
path = Path(self.auth_state_dir)
|
||||
if not path.is_absolute():
|
||||
path = BASE_DIR / path
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
@@ -5,6 +5,7 @@
|
||||
- 2xxx: 抓取资源错误
|
||||
- 3xxx: 反爬/上游阻断相关错误
|
||||
- 4xxx: 页面解析错误
|
||||
- 5xxx: 加购/下单错误(需要账号登录态的写操作)
|
||||
"""
|
||||
|
||||
|
||||
@@ -113,3 +114,58 @@ class OffIchibaRedirectError(AppError):
|
||||
)
|
||||
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)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""浏览器导航请求头构造
|
||||
|
||||
抓取链路与登录态链路都要把 httpx 请求伪装成一次正常的浏览器页面导航,头部结构
|
||||
完全一致,只有 User-Agent 以及随之联动的 `sec-ch-ua-mobile` / `sec-ch-ua-platform`
|
||||
不同。结构留在这里共用,UA 常量则各自持有:
|
||||
|
||||
- 抓取侧的 UA(`scraping/core/site.py`、`scraping/core/rakuma_site.py`)为反爬表现服务,
|
||||
换了只影响抓取成功率。
|
||||
- 登录态侧的 UA(`trading/core/auth_site.py`)必须与人工登录时浏览器用的那一个一致,
|
||||
换了可能触发站点的设备校验,使已落盘的 cookie 直接失效。
|
||||
|
||||
值今天相同,变更理由不同,因此不合并成一份常量。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
ACCEPT_LANGUAGE: Final = "ja,en-US;q=0.9,en;q=0.8"
|
||||
|
||||
|
||||
def navigation_headers(user_agent: str, *, mobile: bool) -> dict[str, str]:
|
||||
"""构造一套完整的浏览器导航请求头
|
||||
|
||||
乐天前置 Akamai Bot Manager,请求头不完整时不会直接封禁,而是把响应拖到
|
||||
~11s(实测与响应体大小无关,22 字节的响应同样耗时 11s);补齐下列头并复用
|
||||
Akamai 下发的 cookie 后,稳定在 ~0.6-0.9s。ラクマ 无此限速,但沿用同一套头
|
||||
没有代价,两侧保持一致更省心。
|
||||
"""
|
||||
return {
|
||||
"User-Agent": user_agent,
|
||||
"Accept": (
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,"
|
||||
"image/avif,image/webp,image/apng,*/*;q=0.8,"
|
||||
"application/signed-exchange;v=b3;q=0.7"
|
||||
),
|
||||
"Accept-Language": ACCEPT_LANGUAGE,
|
||||
"sec-ch-ua": '"Chromium";v="131", "Not_A Brand";v="24", "Google Chrome";v="131"',
|
||||
"sec-ch-ua-mobile": "?1" if mobile else "?0",
|
||||
"sec-ch-ua-platform": '"iOS"' if mobile else '"Windows"',
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
}
|
||||
@@ -13,7 +13,7 @@ from types import FrameType
|
||||
|
||||
from loguru import logger as loguru_logger
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.shared.config import Settings
|
||||
|
||||
|
||||
class InterceptHandler(logging.Handler):
|
||||
@@ -0,0 +1,89 @@
|
||||
"""登录态路由:查询与重新加载账号登录态
|
||||
|
||||
抓取接口全部匿名,只有加购与下单需要账号。登录本身不在这里做——两站登录都要过
|
||||
reCAPTCHA 与设备验证,由 `scripts/login.py` 起有头浏览器人工完成一次,
|
||||
本服务只读取落盘的 cookie。这里提供的是运维视角的两个动作:
|
||||
|
||||
- `/api/auth/status`:现在还登录着吗(默认真实打一次请求探测,不看缓存)
|
||||
- `/api/auth/reload`:人工重新登录后,免重启服务重新读取登录态
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.shared.api import ApiResponse, get_container, require_bearer_token
|
||||
from app.trading.container import TradingContainer
|
||||
from app.trading.models import (
|
||||
AuthReloadData,
|
||||
AuthReloadRequest,
|
||||
AuthSiteStatus,
|
||||
AuthStatusData,
|
||||
AuthStatusRequest,
|
||||
)
|
||||
from app.trading.services.auth_session import AuthStatus
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _to_model(status: AuthStatus) -> AuthSiteStatus:
|
||||
return AuthSiteStatus(**status.to_dict())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/status",
|
||||
response_model=ApiResponse[AuthStatusData],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def auth_status(
|
||||
payload: AuthStatusRequest,
|
||||
container: TradingContainer = Depends(get_container),
|
||||
) -> ApiResponse[AuthStatusData]:
|
||||
"""查询账号登录态
|
||||
|
||||
`refresh=true`(默认)时会真实访问站点探测——登录态过期没有可靠的本地判据,
|
||||
cookie 上的 expires 与服务端会话不是一回事,只能问站点。
|
||||
不需要这次网络往返时传 `refresh=false`,此时返回上一次探测的缓存结果
|
||||
(`logged_in` 为 null 表示从未探测过)。
|
||||
|
||||
`logged_in=false` 时,加购与下单接口会直接返回 5001,需要重新跑
|
||||
`scripts/login.py --site <site>` 后调用 `/api/auth/reload`。
|
||||
"""
|
||||
sites = [payload.site.value] if payload.site else list(container.auth_session.sites)
|
||||
statuses = []
|
||||
for site in sites:
|
||||
status = (
|
||||
await container.auth_session.check(site)
|
||||
if payload.refresh
|
||||
else container.auth_session.status(site)
|
||||
)
|
||||
statuses.append(_to_model(status))
|
||||
|
||||
return ApiResponse[AuthStatusData](
|
||||
success=True,
|
||||
msg="success",
|
||||
data=AuthStatusData(sites=statuses),
|
||||
code=0,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reload",
|
||||
response_model=ApiResponse[AuthReloadData],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def auth_reload(
|
||||
payload: AuthReloadRequest,
|
||||
container: TradingContainer = Depends(get_container),
|
||||
) -> ApiResponse[AuthReloadData]:
|
||||
"""重新从磁盘加载登录态并立即探测
|
||||
|
||||
人工跑完 `scripts/login.py` 后调用,避免为了换一套 cookie 重启整个服务。
|
||||
"""
|
||||
sites = [payload.site.value] if payload.site else list(container.auth_session.sites)
|
||||
reloaded = {site: container.auth_session.reload(site) for site in sites}
|
||||
statuses = [_to_model(await container.auth_session.check(site)) for site in sites]
|
||||
|
||||
return ApiResponse[AuthReloadData](
|
||||
success=True,
|
||||
msg="success",
|
||||
data=AuthReloadData(reloaded=reloaded, sites=statuses),
|
||||
code=0,
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""交易服务健康检查路由"""
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.shared.api import ApiResponse, get_container
|
||||
from app.trading.container import TradingContainer
|
||||
from app.trading.models import TradingHealthData
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health", response_model=ApiResponse[TradingHealthData])
|
||||
async def health(
|
||||
container: TradingContainer = Depends(get_container),
|
||||
) -> ApiResponse[TradingHealthData]:
|
||||
"""交易服务健康状态
|
||||
|
||||
auth 给出两站账号登录态。这里只读缓存、不触发网络探测,避免健康检查被
|
||||
上游高频轮询时反复打站点;要实时结果请用 POST /api/auth/status。
|
||||
|
||||
注意 `logged_in=null` 表示服务启动后还没探测过,不等于未登录。
|
||||
"""
|
||||
return ApiResponse[TradingHealthData](
|
||||
success=True,
|
||||
msg="success",
|
||||
data=TradingHealthData(status="ok", auth=container.auth_session.status_all()),
|
||||
code=0,
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""交易服务容器:集中管理交易侧服务实例,用于依赖注入
|
||||
|
||||
目前只有登录态会话。加购、下单、付款、订单监控与页面证据留痕的服务实例
|
||||
后续挂在这里,它们共享同一份 auth_session——同一个账号的写操作必须走同一条
|
||||
cookie 通道,且必须串行,不能各建各的客户端。
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.shared.config import Settings
|
||||
from app.trading.services.auth_session import AuthSession
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TradingContainer:
|
||||
"""交易服务容器
|
||||
|
||||
与抓取容器最本质的差别不是字段多少,而是**这个进程有状态**:登录态、
|
||||
订单、页面证据都属于某个具体账号,因此交易服务只能单实例运行
|
||||
(或按账号分片),不能像抓取服务那样随意横向扩容。
|
||||
"""
|
||||
|
||||
settings: Settings
|
||||
auth_session: AuthSession
|
||||
@@ -0,0 +1,115 @@
|
||||
"""登录态相关的站点常量
|
||||
|
||||
与抓取用的 `scraping/core/site.py` / `rakuma_site.py` 分开:那两个模块描述的是
|
||||
「匿名抓取怎么拿到页面」,这里描述的是「怎么判断这套 cookie 还登录着」以及登录
|
||||
入口在哪。
|
||||
|
||||
登录态探针的选取原则:挑一个**未登录时行为明确可辨**的页面,而不是靠首页上有没有
|
||||
用户名这类会随改版漂移的文案。两站各自的信号(均为实测):
|
||||
|
||||
- 乐天:手机版购物车页始终返回 200,未登录时正文含「現在ログインしていません」,
|
||||
登录后该串消失。购物车页同时是加购结果的校验页,一页两用。
|
||||
- ラクマ:`/mypage` 未登录时 302 到 `/users/sign_in`,登录后停在 `/mypage`。
|
||||
用落地 URL 判断比翻文案稳。
|
||||
|
||||
每站的这组事实收在 `PROFILES` 里,`scripts/login.py`(起浏览器人工登录)与
|
||||
`AuthSession`(在 httpx 里探测)共用同一份,避免两处各写一遍判据后悄悄漂移。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from app.shared import headers
|
||||
|
||||
# ---- 乐天市场 ----
|
||||
# 手机版购物车。加购走的是 sp.basket 集群,校验也用手机版,保持同一套指纹。
|
||||
RAKUTEN_CART_URL: Final = "https://sp.cart.step.rakuten.co.jp/cart"
|
||||
|
||||
# 登录入口。人工登录时打开这个地址,站点会在登录成功后跳回 my.rakuten.co.jp。
|
||||
RAKUTEN_LOGIN_URL: Final = "https://www.rakuten.co.jp/myrakuten/"
|
||||
|
||||
# 购物车页上表示「当前会话未登录」的文案。出现即判定登录态失效。
|
||||
RAKUTEN_LOGGED_OUT_MARKER: Final = "現在ログインしていません"
|
||||
|
||||
# ---- ラクマ ----
|
||||
RAKUMA_MYPAGE_URL: Final = "https://fril.jp/mypage"
|
||||
RAKUMA_LOGIN_URL: Final = "https://fril.jp/users/sign_in"
|
||||
|
||||
# 未登录时会被重定向到的登录页路径特征
|
||||
RAKUMA_SIGN_IN_PATH: Final = "/users/sign_in"
|
||||
|
||||
# ---- 登录态请求指纹 ----
|
||||
# 这两个 UA 必须与 scripts/login.py 起浏览器时用的一致:cookie 是在那个 UA 下拿到的,
|
||||
# 服务端再拿它发请求时换了 UA,可能触发站点的设备校验让登录态提前失效。
|
||||
#
|
||||
# 值与抓取侧当前相同,但**刻意不复用**抓取侧的常量:抓取 UA 是为绕反爬服务的,
|
||||
# 随时可能为了成功率被调整,那种调整不该波及已落盘的账号 cookie。
|
||||
RAKUTEN_USER_AGENT: Final = (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 "
|
||||
"(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
|
||||
)
|
||||
RAKUMA_USER_AGENT: Final = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SiteAuthProfile:
|
||||
"""一个站点的登录态配置:登录入口、探针、指纹与落盘文件名"""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
login_url: str
|
||||
probe_url: str # 判断登录态是否仍有效的页面
|
||||
user_agent: str
|
||||
mobile: bool
|
||||
state_filename: str # storage_state 落盘文件名(放在 settings.auth_state_dir 下)
|
||||
|
||||
def headers(self) -> dict[str, str]:
|
||||
"""该站登录态请求用的完整导航请求头"""
|
||||
return headers.navigation_headers(self.user_agent, mobile=self.mobile)
|
||||
|
||||
|
||||
PROFILES: Final[dict[str, SiteAuthProfile]] = {
|
||||
"rakuten": SiteAuthProfile(
|
||||
name="rakuten",
|
||||
label="楽天市場",
|
||||
login_url=RAKUTEN_LOGIN_URL,
|
||||
probe_url=RAKUTEN_CART_URL,
|
||||
user_agent=RAKUTEN_USER_AGENT,
|
||||
mobile=True,
|
||||
state_filename="rakuten_state.json",
|
||||
),
|
||||
"rakuma": SiteAuthProfile(
|
||||
name="rakuma",
|
||||
label="ラクマ",
|
||||
login_url=RAKUMA_LOGIN_URL,
|
||||
probe_url=RAKUMA_MYPAGE_URL,
|
||||
user_agent=RAKUMA_USER_AGENT,
|
||||
mobile=False,
|
||||
state_filename="rakuma_state.json",
|
||||
),
|
||||
}
|
||||
|
||||
SITES: Final = tuple(PROFILES)
|
||||
|
||||
|
||||
def profile(site: str) -> SiteAuthProfile:
|
||||
"""取某站的登录态配置,未知站点直接报错"""
|
||||
try:
|
||||
return PROFILES[site]
|
||||
except KeyError:
|
||||
raise ValueError(f"未知站点:{site}") from None
|
||||
|
||||
|
||||
def is_logged_in(site: str, *, final_url: str, body: str) -> bool:
|
||||
"""按该站判据,从探针页的落地 URL 与正文判断是否仍登录着
|
||||
|
||||
两处共用:`AuthSession` 传 httpx 响应的 URL 与文本,`scripts/login.py` 传
|
||||
浏览器页面的 URL 与内容。判据只写一遍,两条链路不会各判各的。
|
||||
"""
|
||||
if site == "rakuten":
|
||||
return RAKUTEN_LOGGED_OUT_MARKER not in body
|
||||
return RAKUMA_SIGN_IN_PATH not in final_url
|
||||
@@ -0,0 +1,84 @@
|
||||
"""交易服务入口:FastAPI 应用创建与生命周期管理
|
||||
|
||||
与抓取服务(app/scraping/main.py)分成两个进程运行,理由不是「要不要登录」
|
||||
这一条,而是运行特性根本不同:
|
||||
|
||||
- 抓取无状态、可重试、可多开实例;这里的写操作**不可逆**,重复提交就是重复下单。
|
||||
- 登录态 cookie 全局唯一,订单监控是常驻轮询;多开实例会让同一个账号被多个
|
||||
进程并发操作,也会让轮询重复触发。
|
||||
- 抓取被限速最多是慢,账号被风控是封号;两者不该共用出口 IP 与请求节奏。
|
||||
|
||||
因此本服务只能单实例运行(或按账号分片),扩容靠抓取服务那一侧。
|
||||
|
||||
当前只提供登录态的查询与重载;加购、下单、付款与订单监控在此基础上叠加。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.shared.api import register_exception_handlers
|
||||
from app.shared.config import get_settings
|
||||
from app.shared.logging_setup import configure_logging
|
||||
from app.trading.api.routes.auth import router as auth_router
|
||||
from app.trading.api.routes.health import router as health_router
|
||||
from app.trading.container import TradingContainer
|
||||
from app.trading.services.auth_session import AuthSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_container() -> TradingContainer:
|
||||
"""构建交易服务容器,组装所有依赖"""
|
||||
settings = get_settings()
|
||||
return TradingContainer(settings=settings, auth_session=AuthSession(settings))
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理:启动时加载登录态,关闭时释放 HTTP 客户端"""
|
||||
container = build_container()
|
||||
app.state.container = container
|
||||
|
||||
configure_logging(container.settings)
|
||||
logger.info(
|
||||
"交易服务启动:%s:%s",
|
||||
container.settings.trading_host,
|
||||
container.settings.trading_port,
|
||||
)
|
||||
logger.info("当前环境:%s", container.settings.app_env)
|
||||
await container.auth_session.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await container.auth_session.close()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""创建 FastAPI 应用实例,注册路由和异常处理器"""
|
||||
app = FastAPI(title="Rakuten Trading Service", lifespan=lifespan)
|
||||
app.include_router(health_router)
|
||||
app.include_router(auth_router)
|
||||
register_exception_handlers(app)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
settings = get_settings()
|
||||
configure_logging(settings)
|
||||
uvicorn.run(
|
||||
"app.trading.main:app",
|
||||
host=settings.trading_host,
|
||||
port=settings.trading_port,
|
||||
log_config=None,
|
||||
timeout_keep_alive=120,
|
||||
# 单进程:登录态与后续的订单监控都不能有第二份
|
||||
workers=1,
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""交易侧 API 数据模型:登录态、(后续的)加购、下单与订单监控
|
||||
|
||||
与抓取侧模型(app/scraping/models/scrape.py)分开的理由和服务本身拆开的理由
|
||||
一致:抓取模型描述的是「站点上有什么」,这里描述的是「我们对某个账号做了什么、
|
||||
现在处于哪一步」——后者有生命周期、有状态迁移,会持久化,不是一次请求的产物。
|
||||
|
||||
响应信封 `ApiResponse` 与抓取侧共用,在 app/shared/api.py。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AuthSite(StrEnum):
|
||||
"""支持登录的站点"""
|
||||
|
||||
RAKUTEN = "rakuten"
|
||||
RAKUMA = "rakuma"
|
||||
|
||||
|
||||
class AuthStatusRequest(BaseModel):
|
||||
"""登录态查询请求"""
|
||||
|
||||
site: AuthSite | None = None # 不传则返回两站
|
||||
refresh: bool = True # 是否真实打一次请求探测;false 时只读缓存
|
||||
|
||||
|
||||
class AuthSiteStatus(BaseModel):
|
||||
"""单站登录态"""
|
||||
|
||||
site: str
|
||||
state_file_exists: bool # 是否已跑过 scripts/login.py
|
||||
logged_in: bool | None # None 表示尚未探测
|
||||
checked_age_seconds: float | None = None
|
||||
detail: str = ""
|
||||
|
||||
|
||||
class AuthStatusData(BaseModel):
|
||||
"""登录态查询响应"""
|
||||
|
||||
sites: list[AuthSiteStatus] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AuthReloadRequest(BaseModel):
|
||||
"""重新加载登录态请求(人工登录完成后调用,免重启服务)"""
|
||||
|
||||
site: AuthSite | None = None # 不传则两站都重载
|
||||
|
||||
|
||||
class AuthReloadData(BaseModel):
|
||||
"""重新加载登录态响应"""
|
||||
|
||||
reloaded: dict[str, int] = Field(default_factory=dict) # site -> cookie 条数
|
||||
sites: list[AuthSiteStatus] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TradingHealthData(BaseModel):
|
||||
"""交易服务健康检查响应数据
|
||||
|
||||
只读缓存的登录态,不触发网络探测——健康检查会被高频轮询,实时结果请用
|
||||
POST /api/auth/status。
|
||||
"""
|
||||
|
||||
status: str
|
||||
auth: dict[str, Any] = Field(default_factory=dict)
|
||||
@@ -0,0 +1,260 @@
|
||||
"""登录态会话:持有已登录账号的 cookie,供加购与下单链路使用
|
||||
|
||||
与抓取链路(app/scraping 的 site_session / rakuma_session)不只是分模块,
|
||||
而是分进程运行,原因:
|
||||
|
||||
- 抓取是**匿名**的,cookie 只用来过 Akamai 限速,丢了重新预热即可,无状态可言。
|
||||
- 加购下单必须**带账号**,cookie 一旦失效不能自动恢复——乐天与 ラクマ 登录都有
|
||||
reCAPTCHA 与设备验证,只能由人重新登录一次。因此这里的失效处理是「明确报错让
|
||||
上游停下」,而不是像抓取那样静默重试。
|
||||
- 这份登录态在整个系统里只能有一份。跟抓取同进程的话,抓取一扩容就会复制出 N 份
|
||||
登录态与 N 个订单轮询,同一个账号被并发操作。
|
||||
|
||||
登录态来源是 Playwright 的 storage_state:由 `scripts/login.py` 起一个有头浏览器
|
||||
让人工登录一次后落盘,本服务只读取、不生产。账号密码不经过本服务,也不写日志。
|
||||
|
||||
cookie 会被同时喂给两处:
|
||||
- httpx 客户端 —— 加购这类纯表单提交走 HTTP 更快
|
||||
- Playwright context —— 下单确认页有 JS 参与,必要时用浏览器走完
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.shared.config import Settings
|
||||
from app.shared.errors import NotLoggedInError, UpstreamRequestError
|
||||
from app.trading.core import auth_site
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AuthStatus:
|
||||
"""一个站点的登录态快照"""
|
||||
|
||||
site: str
|
||||
state_file_exists: bool
|
||||
logged_in: bool | None # None 表示尚未探测过
|
||||
checked_at: float | None = None
|
||||
detail: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"site": self.site,
|
||||
"state_file_exists": self.state_file_exists,
|
||||
"logged_in": self.logged_in,
|
||||
"checked_age_seconds": (
|
||||
round(time.monotonic() - self.checked_at, 1) if self.checked_at else None
|
||||
),
|
||||
"detail": self.detail,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _SiteAuth:
|
||||
"""一个站点的登录通道:独立的 httpx 客户端与登录态缓存"""
|
||||
|
||||
name: str
|
||||
client: httpx.AsyncClient
|
||||
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
logged_in: bool | None = None
|
||||
checked_at: float | None = None
|
||||
detail: str = ""
|
||||
|
||||
|
||||
class AuthSession:
|
||||
"""持有两站登录态的会话
|
||||
|
||||
职责边界:只负责「有没有登录态、cookie 是什么、还有效吗」,
|
||||
具体加购/下单的业务请求由各自的 client 组装后借这里的 HTTP 客户端发出。
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings):
|
||||
self._settings = settings
|
||||
self._sites: dict[str, _SiteAuth] = {}
|
||||
|
||||
# ---- 生命周期 ----
|
||||
|
||||
async def start(self) -> None:
|
||||
"""为两站创建带登录 cookie 的 HTTP 客户端
|
||||
|
||||
登录态文件不存在时同样创建客户端(只是没有 cookie),这样 /health 与
|
||||
/api/auth/status 能如实回报「未登录」,而不是整个服务起不来。
|
||||
"""
|
||||
for name, profile in auth_site.PROFILES.items():
|
||||
if name in self._sites:
|
||||
continue
|
||||
client = httpx.AsyncClient(
|
||||
headers=profile.headers(),
|
||||
timeout=self._settings.request_timeout_seconds,
|
||||
follow_redirects=True,
|
||||
proxy=self._settings.httpx_proxy,
|
||||
http2=True,
|
||||
)
|
||||
self._sites[name] = _SiteAuth(name=name, client=client)
|
||||
loaded = self._load_cookies(name)
|
||||
logger.info("登录通道已就绪:site=%s cookies=%s", name, loaded)
|
||||
|
||||
async def close(self) -> None:
|
||||
for auth in self._sites.values():
|
||||
try:
|
||||
await auth.client.aclose()
|
||||
except Exception:
|
||||
logger.debug("关闭登录 HTTP 客户端失败:site=%s", auth.name, exc_info=True)
|
||||
self._sites.clear()
|
||||
|
||||
# ---- 登录态文件 ----
|
||||
|
||||
def state_path(self, site: str) -> Path:
|
||||
"""某站点 storage_state 文件的落盘路径"""
|
||||
return self._settings.auth_state_path / auth_site.profile(site).state_filename
|
||||
|
||||
def _load_cookies(self, site: str) -> int:
|
||||
"""把 storage_state 里的 cookie 灌进该站的 httpx 客户端,返回条数"""
|
||||
path = self.state_path(site)
|
||||
if not path.exists():
|
||||
return 0
|
||||
|
||||
try:
|
||||
state = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("登录态文件读取失败:site=%s path=%s err=%s", site, path, exc)
|
||||
return 0
|
||||
|
||||
auth = self._sites[site]
|
||||
auth.client.cookies.clear()
|
||||
count = 0
|
||||
for cookie in state.get("cookies", []):
|
||||
name = cookie.get("name")
|
||||
value = cookie.get("value")
|
||||
if not name or value is None:
|
||||
continue
|
||||
auth.client.cookies.set(
|
||||
name,
|
||||
value,
|
||||
domain=cookie.get("domain") or "",
|
||||
path=cookie.get("path") or "/",
|
||||
)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def reload(self, site: str) -> int:
|
||||
"""重新从磁盘加载登录态(人工登录完成后调用),返回 cookie 条数"""
|
||||
auth = self._sites.get(site)
|
||||
if auth is None:
|
||||
raise ValueError(f"未知站点:{site}")
|
||||
count = self._load_cookies(site)
|
||||
auth.logged_in = None
|
||||
auth.checked_at = None
|
||||
auth.detail = "已重新加载登录态,尚未探测"
|
||||
logger.info("登录态已重新加载:site=%s cookies=%s", site, count)
|
||||
return count
|
||||
|
||||
# ---- 登录态探测 ----
|
||||
|
||||
async def check(self, site: str) -> AuthStatus:
|
||||
"""探测某站登录态是否仍然有效
|
||||
|
||||
每次都真实打一次请求——登录态过期没有可靠的本地判据(cookie 的 expires
|
||||
与服务端会话不是一回事),只能问站点。
|
||||
"""
|
||||
auth = self._require_site(site)
|
||||
async with auth.lock:
|
||||
try:
|
||||
if site == "rakuten":
|
||||
logged_in, detail = await self._check_rakuten(auth)
|
||||
else:
|
||||
logged_in, detail = await self._check_rakuma(auth)
|
||||
except httpx.HTTPError as exc:
|
||||
raise UpstreamRequestError(
|
||||
f"登录态探测请求失败:site={site} err={type(exc).__name__}: {exc}"
|
||||
) from exc
|
||||
|
||||
auth.logged_in = logged_in
|
||||
auth.checked_at = time.monotonic()
|
||||
auth.detail = detail
|
||||
logger.info("登录态探测:site=%s logged_in=%s detail=%s", site, logged_in, detail)
|
||||
return self.status(site)
|
||||
|
||||
async def _check_rakuten(self, auth: _SiteAuth) -> tuple[bool, str]:
|
||||
"""购物车页含「現在ログインしていません」即未登录"""
|
||||
response = await auth.client.get(auth_site.PROFILES["rakuten"].probe_url)
|
||||
if response.status_code >= 400:
|
||||
return False, f"购物车页返回 status {response.status_code}"
|
||||
if not auth_site.is_logged_in(
|
||||
"rakuten", final_url=str(response.url), body=response.text
|
||||
):
|
||||
return False, "购物车页显示未登录"
|
||||
return True, "购物车页未出现未登录标记"
|
||||
|
||||
async def _check_rakuma(self, auth: _SiteAuth) -> tuple[bool, str]:
|
||||
"""/mypage 被重定向到 /users/sign_in 即未登录"""
|
||||
response = await auth.client.get(auth_site.PROFILES["rakuma"].probe_url)
|
||||
if response.status_code >= 400:
|
||||
return False, f"mypage 返回 status {response.status_code}"
|
||||
if not auth_site.is_logged_in(
|
||||
"rakuma", final_url=str(response.url), body=response.text
|
||||
):
|
||||
return False, "mypage 被重定向至登录页"
|
||||
return True, "mypage 正常返回"
|
||||
|
||||
async def require_logged_in(self, site: str) -> None:
|
||||
"""确保某站处于登录态,否则抛错
|
||||
|
||||
加购与下单前的统一入口。登录态失效时不做任何自动恢复尝试——两站登录都需要
|
||||
人工过验证码,只能让上游停下来重新跑一次 scripts/login.py。
|
||||
"""
|
||||
status = await self.check(site)
|
||||
if not status.logged_in:
|
||||
raise NotLoggedInError(site=site, detail=status.detail)
|
||||
|
||||
# ---- 对外访问 ----
|
||||
|
||||
@property
|
||||
def sites(self) -> tuple[str, ...]:
|
||||
"""已初始化的站点名"""
|
||||
return tuple(self._sites)
|
||||
|
||||
def client(self, site: str) -> httpx.AsyncClient:
|
||||
"""取该站带登录 cookie 的 HTTP 客户端"""
|
||||
return self._require_site(site).client
|
||||
|
||||
def cookies_for_browser(self, site: str) -> list[dict[str, Any]]:
|
||||
"""导出 cookie 供 Playwright context 使用"""
|
||||
path = self.state_path(site)
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
state = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
return list(state.get("cookies", []))
|
||||
|
||||
def status(self, site: str) -> AuthStatus:
|
||||
"""该站登录态快照(不触发探测,用缓存结果)"""
|
||||
auth = self._require_site(site)
|
||||
return AuthStatus(
|
||||
site=site,
|
||||
state_file_exists=self.state_path(site).exists(),
|
||||
logged_in=auth.logged_in,
|
||||
checked_at=auth.checked_at,
|
||||
detail=auth.detail,
|
||||
)
|
||||
|
||||
def status_all(self) -> dict[str, dict[str, Any]]:
|
||||
"""两站登录态快照,供健康检查展示"""
|
||||
return {name: self.status(name).to_dict() for name in self._sites}
|
||||
|
||||
def _require_site(self, site: str) -> _SiteAuth:
|
||||
auth = self._sites.get(site)
|
||||
if auth is None:
|
||||
raise ValueError(f"未知站点或登录会话未初始化:{site}")
|
||||
return auth
|
||||
Reference in New Issue
Block a user