Init
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"""应用配置:通过环境变量和 .env 文件加载所有配置项
|
||||
|
||||
配置项统一使用 RAKUTEN_ 前缀,例如 RAKUTEN_APP_PORT=31107。
|
||||
支持 .env 文件自动加载。
|
||||
"""
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from app.core import site
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用全局配置
|
||||
|
||||
所有配置项均可通过环境变量覆盖,前缀为 RAKUTEN_。
|
||||
例如:RAKUTEN_APP_PORT=31107 对应 app_port 配置项。
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=str(BASE_DIR / ".env"),
|
||||
env_file_encoding="utf-8",
|
||||
env_prefix="RAKUTEN_",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# ---- 服务基本配置 ----
|
||||
app_name: str = "Rakuten Scraper Service"
|
||||
app_env: Literal["dev", "prod", "test"] = "dev"
|
||||
app_host: str = "0.0.0.0"
|
||||
app_port: int = 31107
|
||||
|
||||
# ---- 日志配置 ----
|
||||
log_level: str = "INFO"
|
||||
log_to_file: bool | None = None # None 表示根据环境自动决定
|
||||
log_dir: str = "logs"
|
||||
log_rotation: str = "100 MB"
|
||||
log_retention: str = "14 days"
|
||||
log_compression: str = "zip"
|
||||
log_format: str = "{time:YYYY-MM-DD HH:mm:ss} {level} {message}"
|
||||
log_enqueue: bool = True
|
||||
|
||||
# ---- 鉴权配置 ----
|
||||
bearer_token: str = "REPLACE_WITH_TOKEN_32CHARS"
|
||||
|
||||
# ---- 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
|
||||
browser_headless: bool | None = None # None 表示根据环境自动决定
|
||||
browser_channel: str | None = None # 例如 chrome;留空使用 bundled chromium
|
||||
browser_launch_timeout_seconds: float = 60.0
|
||||
browser_nav_timeout_seconds: float = 60.0
|
||||
|
||||
# ---- 代理配置(可选,用于日本 IP)----
|
||||
proxy_server: str | None = None
|
||||
proxy_username: str | None = None
|
||||
proxy_password: str | None = None
|
||||
|
||||
# ---- 目标站点 ----
|
||||
home_url: str = site.HOME_URL
|
||||
|
||||
@property
|
||||
def browser_headless_effective(self) -> bool:
|
||||
"""浏览器无头模式:显式配置优先,否则开发环境使用有头模式方便调试"""
|
||||
if self.browser_headless is not None:
|
||||
return self.browser_headless
|
||||
return self.app_env != "dev"
|
||||
|
||||
@property
|
||||
def log_to_file_effective(self) -> bool:
|
||||
"""是否写日志文件:显式配置优先,否则生产环境默认写文件"""
|
||||
if self.log_to_file is not None:
|
||||
return self.log_to_file
|
||||
return self.app_env == "prod"
|
||||
|
||||
@property
|
||||
def playwright_proxy(self) -> dict[str, str] | None:
|
||||
"""构建 Playwright 代理配置字典"""
|
||||
if not self.proxy_server:
|
||||
return None
|
||||
|
||||
proxy: dict[str, str] = {"server": self.proxy_server}
|
||||
if self.proxy_username:
|
||||
proxy["username"] = self.proxy_username
|
||||
if self.proxy_password:
|
||||
proxy["password"] = self.proxy_password
|
||||
return proxy
|
||||
|
||||
@property
|
||||
def httpx_proxy(self) -> str | None:
|
||||
"""构建 httpx 代理 URL(含认证信息)"""
|
||||
if not self.proxy_server:
|
||||
return None
|
||||
if not self.proxy_username:
|
||||
return self.proxy_server
|
||||
|
||||
scheme, _, rest = self.proxy_server.partition("://")
|
||||
if not rest:
|
||||
return self.proxy_server
|
||||
credentials = f"{self.proxy_username}:{self.proxy_password or ''}"
|
||||
return f"{scheme}://{credentials}@{rest}"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
"""获取全局配置单例"""
|
||||
return Settings()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""服务容器:集中管理所有服务实例,用于依赖注入"""
|
||||
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
|
||||
@@ -0,0 +1,115 @@
|
||||
"""应用异常定义:所有业务异常均继承自 AppError
|
||||
|
||||
错误码规范:
|
||||
- 1xxx: 请求/鉴权错误
|
||||
- 2xxx: 抓取资源错误
|
||||
- 3xxx: 反爬/上游阻断相关错误
|
||||
- 4xxx: 页面解析错误
|
||||
"""
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,86 @@
|
||||
"""日志配置:使用 loguru 替代标准 logging,统一管理日志输出
|
||||
|
||||
- 控制台输出:始终启用
|
||||
- 文件输出:生产环境默认启用,支持日志轮转、压缩和保留策略
|
||||
- 拦截标准 logging:将 uvicorn 等第三方库的日志转发到 loguru
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import FrameType
|
||||
|
||||
from loguru import logger as loguru_logger
|
||||
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
class InterceptHandler(logging.Handler):
|
||||
"""拦截标准 logging 日志,转发到 loguru"""
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
level: str | int = loguru_logger.level(record.levelname).name
|
||||
except ValueError:
|
||||
level = record.levelno
|
||||
|
||||
frame: FrameType | None = logging.currentframe()
|
||||
depth = 2
|
||||
while frame and frame.f_code.co_filename == logging.__file__:
|
||||
frame = frame.f_back
|
||||
depth += 1
|
||||
|
||||
loguru_logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
|
||||
|
||||
|
||||
def configure_logging(settings: Settings) -> None:
|
||||
"""根据配置初始化日志系统
|
||||
|
||||
- 移除 loguru 默认 handler
|
||||
- 添加控制台输出
|
||||
- 生产环境添加文件输出(轮转、压缩、保留策略)
|
||||
- 拦截标准 logging,统一走 loguru 输出
|
||||
"""
|
||||
loguru_logger.remove()
|
||||
loguru_logger.add(
|
||||
sys.stdout,
|
||||
level=settings.log_level.upper(),
|
||||
format=settings.log_format,
|
||||
enqueue=settings.log_enqueue,
|
||||
backtrace=False,
|
||||
diagnose=False,
|
||||
)
|
||||
|
||||
if settings.log_to_file_effective:
|
||||
log_dir = Path(settings.log_dir)
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = log_dir / f"app_{settings.app_env}.log"
|
||||
loguru_logger.add(
|
||||
str(log_path),
|
||||
level=settings.log_level.upper(),
|
||||
format=settings.log_format,
|
||||
rotation=settings.log_rotation,
|
||||
retention=settings.log_retention,
|
||||
compression=settings.log_compression,
|
||||
enqueue=settings.log_enqueue,
|
||||
encoding="utf-8",
|
||||
backtrace=False,
|
||||
diagnose=False,
|
||||
)
|
||||
|
||||
intercept = InterceptHandler()
|
||||
logging.basicConfig(
|
||||
handlers=[intercept],
|
||||
level=getattr(logging, settings.log_level.upper(), logging.INFO),
|
||||
force=True,
|
||||
)
|
||||
|
||||
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
||||
logger = logging.getLogger(name)
|
||||
logger.handlers = [intercept]
|
||||
logger.propagate = False
|
||||
|
||||
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
||||
# httpx 每次请求都会打一条 INFO,抓取服务下噪音过大
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
@@ -0,0 +1,110 @@
|
||||
"""ラクマ(fril.jp)站点常量
|
||||
|
||||
集中维护站点入口 URL、浏览器指纹参数,以及搜索页 URL 的排序码 / 筛选码映射。
|
||||
|
||||
排序与筛选参数并非猜测,而是从站点前端 bundle
|
||||
(asset.fril.jp/assets/v2/application-*.js)中 SearchPanel 组件的 `_url()`
|
||||
方法里提取的——那段代码逐条拼出搜索 URL,因此参数名与取值与站点行为一一对应。
|
||||
|
||||
与乐天市场(app/core/site.py)的关键差异:
|
||||
- 页面是服务端渲染的 HTML,**没有** `window.__INITIAL_STATE__`,只能解析 DOM
|
||||
- 前置的不是 Akamai Bot Manager,无限速行为,冷请求即 ~0.6-1.1s,不需要 cookie 预热
|
||||
- 商品详情页用 PC UA 即可,不需要像乐天那样切手机 UA
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
# ---- 站点入口 ----
|
||||
HOME_URL: Final = "https://fril.jp/"
|
||||
SEARCH_BASE_URL: Final = "https://fril.jp/s"
|
||||
ITEM_BASE_URL: Final = "https://item.fril.jp/"
|
||||
SHOP_BASE_URL: Final = "https://fril.jp/shop/"
|
||||
CATEGORY_BASE_URL: Final = "https://fril.jp/category/"
|
||||
BRAND_BASE_URL: Final = "https://fril.jp/brand/"
|
||||
|
||||
SEARCH_HOST: Final = "fril.jp"
|
||||
ITEM_HOST: Final = "item.fril.jp"
|
||||
|
||||
# 站点每页固定返回 40 条(实测第 1 页与深翻页均为 40)
|
||||
PAGE_SIZE: Final = 40
|
||||
|
||||
# 站点侧翻页上限:page=100 正常返回,page=101 起直接 404。
|
||||
MAX_PAGE: Final = 100
|
||||
|
||||
# ---- 浏览器指纹 ----
|
||||
# 两类页面都用 PC UA:详情页 PC 版即为完整模板,无需像乐天那样切手机 UA。
|
||||
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"
|
||||
)
|
||||
|
||||
ACCEPT_LANGUAGE: Final = "ja,en-US;q=0.9,en;q=0.8"
|
||||
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
# ---- 排序(搜索页 `sort=` + `order=` 两个参数)----
|
||||
# 键为对外暴露的语义化排序名,值为 (sort, order)。
|
||||
# 站点把排序字段与升降序拆成两个参数,因此这里成对给出。
|
||||
SORT_CODES: Final[dict[str, tuple[str, str]]] = {
|
||||
"standard": ("relevance", "desc"), # 站点默认的おすすめ順
|
||||
"newest": ("created_at", "desc"),
|
||||
"price_asc": ("sell_price", "asc"),
|
||||
"price_desc": ("sell_price", "desc"),
|
||||
"like_count": ("like_count", "desc"), # いいね数順
|
||||
}
|
||||
|
||||
# ---- 商品状态(搜索页 `statuses=`,逗号分隔可多选)----
|
||||
# 取值来自 SearchPanel 组件的 statusOptions 定义。
|
||||
CONDITION_CODES: Final[dict[str, str]] = {
|
||||
"new": "5", # 新品、未使用
|
||||
"almost_new": "4", # 未使用に近い
|
||||
"no_damage": "6", # 目立った傷や汚れなし
|
||||
"slight_damage": "3", # やや傷や汚れあり
|
||||
"damaged": "2", # 傷や汚れあり
|
||||
"poor": "1", # 全体的に状態が悪い
|
||||
}
|
||||
|
||||
# ---- 售卖状态(搜索页 `transaction=`)----
|
||||
# 站点默认 all(不带该参数),仅在筛选时下发。
|
||||
TRANSACTION_CODES: Final[dict[str, str]] = {
|
||||
"on_sale": "selling", # 販売中のみ
|
||||
"sold_out": "soldout", # 売切れのみ
|
||||
}
|
||||
|
||||
# ---- 鉴定服务(搜索页 `authenticity_types=`)----
|
||||
AUTHENTICITY_CODES: Final[dict[str, str]] = {
|
||||
"before_delivery": "pre", # お届け前鑑定
|
||||
"after_delivery": "post", # 後から鑑定
|
||||
}
|
||||
|
||||
# 运费负担:站点用 carriage=1 表示「送料込みのみ」(卖家承担),0 表示不限
|
||||
CARRIAGE_INCLUDED: Final = "1"
|
||||
|
||||
# 商品详情页上表示已售出的标记文案
|
||||
SOLD_OUT_MARKERS: Final = ("SOLD OUT", "SOLDOUT", "売り切れました")
|
||||
|
||||
# 页面校验用的结构标记:这几类页面各自必须出现的 DOM 特征
|
||||
SEARCH_PAGE_MARKER: Final = "page-count"
|
||||
ITEM_PAGE_MARKER: Final = "item-info"
|
||||
SHOP_PAGE_MARKER: Final = "profile-area__shop-name"
|
||||
@@ -0,0 +1,107 @@
|
||||
"""乐天市场(rakuten.co.jp)站点常量
|
||||
|
||||
集中维护站点入口 URL、浏览器指纹参数,以及搜索页 URL 的排序码 / 筛选码映射。
|
||||
|
||||
排序码(`s=`)与筛选码(`f=`)并非猜测所得,而是从搜索页前端 bundle
|
||||
(r.r10s.jp/com/assets/app/pages/search/javascript/pc-*.bundle.js)中的
|
||||
URL→UiQuestion 转换逻辑里提取的枚举,与站点行为一一对应。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
# ---- 站点入口 ----
|
||||
HOME_URL: Final = "https://www.rakuten.co.jp/"
|
||||
SEARCH_BASE_URL: Final = "https://search.rakuten.co.jp/search/mall/"
|
||||
ITEM_BASE_URL: Final = "https://item.rakuten.co.jp/"
|
||||
|
||||
CATEGORY_BASE_URL: Final = "https://www.rakuten.co.jp/category/"
|
||||
|
||||
# 店铺首页形如 https://www.rakuten.co.jp/edion/
|
||||
WWW_BASE_URL: Final = "https://www.rakuten.co.jp/"
|
||||
|
||||
SEARCH_HOST: Final = "search.rakuten.co.jp"
|
||||
ITEM_HOST: Final = "item.rakuten.co.jp"
|
||||
WWW_HOST: Final = "www.rakuten.co.jp"
|
||||
|
||||
# 取顶层分类列表用的哨兵关键词。
|
||||
# 站点的分类分面(genreTree)与查询内容无关,任何关键词都会返回同一套 39 个顶层
|
||||
# 分类;刻意用一个搜不到东西的词,可以拿到 count 全为 null 的干净列表,避免把
|
||||
# 「某个关键词下的命中数」误当成分类的商品总数返回。
|
||||
# 注意不能用单字母(如 a),站点对过短的拉丁关键词直接返回 503。
|
||||
GENRE_FACET_PROBE_KEYWORD: Final = "zzzqqqxyz123"
|
||||
|
||||
# 站点侧限制:搜索结果最多只能翻到 pagination.subset 条(实测 6750),
|
||||
# 再往后翻页返回空列表。用于计算 has_more,避免上游无意义地深翻。
|
||||
DEFAULT_SUBSET_LIMIT: Final = 6750
|
||||
|
||||
# ---- 浏览器指纹 ----
|
||||
# 搜索页用 PC UA;商品详情页必须用手机 UA,否则返回的是各店铺自定义的
|
||||
# EUC-JP 老模板(无 __INITIAL_STATE__,只有面包屑 JSON-LD,无法结构化解析)。
|
||||
PC_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"
|
||||
)
|
||||
SP_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"
|
||||
)
|
||||
|
||||
ACCEPT_LANGUAGE: Final = "ja,en-US;q=0.9,en;q=0.8"
|
||||
|
||||
# 乐天前置 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",
|
||||
}
|
||||
|
||||
|
||||
# Akamai Bot Manager 下发的 cookie:判断会话是否已预热完成的依据
|
||||
AKAMAI_COOKIE_NAMES: Final = ("ak_bmsc", "bm_sv", "bm_mi", "_abck")
|
||||
|
||||
# ---- 排序(搜索页 `s=` 参数)----
|
||||
# 键为对外暴露的语义化排序名,值为站点侧排序码;standard 不带 s 参数。
|
||||
SORT_CODES: Final[dict[str, str | None]] = {
|
||||
"standard": None, # 站点默认的相关度排序(relevancy)
|
||||
"price_asc": "2",
|
||||
"price_desc": "3",
|
||||
"newest": "4",
|
||||
"review_count": "5",
|
||||
"review_score": "6",
|
||||
"price_with_shipping_asc": "11",
|
||||
"price_with_shipping_desc": "12",
|
||||
}
|
||||
|
||||
# ---- 成色(搜索页 `f=` 参数中的 condition 段)----
|
||||
CONDITION_CODES: Final[dict[str, str]] = {
|
||||
"new": "101",
|
||||
"used": "100",
|
||||
"rental": "102",
|
||||
}
|
||||
|
||||
# ---- 布尔筛选(搜索页 `f=` 参数,可重复出现)----
|
||||
# 字段名 -> 筛选码
|
||||
BOOL_FILTER_CODES: Final[dict[str, str]] = {
|
||||
"include_sold_out": "0",
|
||||
"free_shipping": "2",
|
||||
"has_review": "4",
|
||||
"next_day_delivery": "12",
|
||||
"super_deal": "13",
|
||||
}
|
||||
Reference in New Issue
Block a user