diff --git a/.dockerignore b/.dockerignore index 98c91ef..93f0bf8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,3 +5,12 @@ tests docs __pycache__ *.pyc +.venv +*.egg-info +.pytest_cache +.ruff_cache +.coverage +htmlcov +*.db +browser_profile +uv.lock diff --git a/.env.example b/.env.example index db6afd9..c25efc0 100644 --- a/.env.example +++ b/.env.example @@ -57,10 +57,16 @@ DEFAULT_CRAWL_JITTER_MINUTES=10 # Docker 端口绑定地址:本机使用 127.0.0.1;NAS 需要局域网访问时可填 NAS 局域网 IP(不建议无认证暴露到公网) BIND_ADDRESS=127.0.0.1 -# SQLite 数据库文件路径 +# Docker 镜像标签:本地构建时用 second-eye:local;也可指向已发布镜像 +SECOND_EYE_IMAGE=second-eye:local + +# SQLite 数据库文件路径(Docker 部署时会被 compose.yml 的 environment 覆盖为 +# sqlite:////app/data/goodprice.db,容器内改这里不生效) DATABASE_URL=sqlite:///data/goodprice.db # Docker noVNC:默认关闭。临时登录时设置 ENABLE_NOVNC=1,并在 data/.novnc-password # 写入独立密码;登录完成后恢复为 0 并重启容器。 ENABLE_NOVNC=0 NOVNC_PASSWORD_FILE=/app/data/.novnc-password +# noVNC 对外端口(compose 端口映射与登录页链接共用;需与 BIND_ADDRESS 一起对外可达) +NOVNC_PORT=16080 diff --git a/README.md b/README.md index fc32ef1..78c178f 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Linux/macOS/NAS 可以使用仓库提供的跨平台辅助脚本: ./scripts/start-docker.sh --login ~~~ -脚本会生成 data/.novnc-password,启动容器并提示访问地址。默认访问: +脚本会生成 data/.novnc-password,启动容器并提示访问地址。默认访问(端口可用 NOVNC_PORT 修改): ~~~text http://127.0.0.1:16080/vnc.html diff --git a/compose.yml b/compose.yml index c6090a7..033de65 100644 --- a/compose.yml +++ b/compose.yml @@ -13,7 +13,7 @@ services: ports: # Bind to localhost by default: the app has no built-in authentication. - "${BIND_ADDRESS:-127.0.0.1}:18000:8000" - - "${BIND_ADDRESS:-127.0.0.1}:16080:6080" + - "${BIND_ADDRESS:-127.0.0.1}:${NOVNC_PORT:-16080}:6080" environment: DATABASE_URL: sqlite:////app/data/goodprice.db PROXY: ${PROXY:-} @@ -36,7 +36,7 @@ services: start_period: 30s gotify: - image: gotify/server:latest + image: gotify/server:3.1.1 container_name: second-eye-gotify restart: unless-stopped ports: diff --git a/goodprice/__init__.py b/goodprice/__init__.py index dd6256a..603cd90 100644 --- a/goodprice/__init__.py +++ b/goodprice/__init__.py @@ -1,4 +1,9 @@ """闲鱼盯价助手:本地 Web 工具。""" -__version__ = "0.1.0" +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _pkg_version +try: # 版本号只在 pyproject.toml 定义一处,运行时从包元数据读取。 + __version__ = _pkg_version("good-price") +except PackageNotFoundError: # 源码直接运行、未安装时 + __version__ = "0.0.0" diff --git a/goodprice/analysis/jev_typesafe.py b/goodprice/analysis/jev_typesafe.py index 6f817a1..5299330 100644 --- a/goodprice/analysis/jev_typesafe.py +++ b/goodprice/analysis/jev_typesafe.py @@ -6,13 +6,15 @@ from typing import Any, Callable, Optional from goodprice.analysis.judge import RequirementVerdict +from goodprice.analysis.prompts import REQUIREMENT_CRITERION, VALUE_CRITERION +from goodprice.constants import SCORE_MAX, SCORE_MIN logger = logging.getLogger(__name__) -VALUE_LEVELS = [f"{i} 分" for i in range(1, 11)] # Score 评分为索引空间 [0, 9] +VALUE_LEVELS = [f"{i} 分" for i in range(SCORE_MIN, SCORE_MAX + 1)] # SDK 返回 0–9 索引,+1 映射到 1–10 -REQUIREMENT_NOUL_INSTRUCTION = "该二手商品是否满足买家的硬性需求?" -VALUE_SCORE_INSTRUCTION = "该商品按当前价格是否划算,1 分最不划算,10 分最划算" +REQUIREMENT_NOUL_INSTRUCTION = f"该二手{REQUIREMENT_CRITERION}?" +VALUE_SCORE_INSTRUCTION = f"该商品{VALUE_CRITERION},1 分最不划算,10 分最划算" @dataclass @@ -28,7 +30,7 @@ def enabled(self) -> bool: def analyze_requirement( self, title: str, description: str = "", requirement: str = "" - ) -> dict[str, Any]: + ) -> RequirementVerdict: if not self.enabled: raise RuntimeError("TypeSafe API Key 未配置") state = ( @@ -66,7 +68,7 @@ def analyze_batch_value(self, items: list[dict[str, Any]]) -> dict[str, Any]: logger.warning("TypeSafe 性价比评估失败,跳过 %s: %s", external_id, exc) continue raw = float(answer.score) - scores[external_id] = min(10, max(1, int(round(raw)) + 1)) + scores[external_id] = min(SCORE_MAX, max(SCORE_MIN, int(round(raw)) + 1)) confidences[external_id] = float(getattr(answer, "confidence", 0.0)) reasons[external_id] = f"官方 Jev Score={raw:.1f}" best = None diff --git a/goodprice/analysis/json_extract.py b/goodprice/analysis/json_extract.py index 7112bd5..7591288 100644 --- a/goodprice/analysis/json_extract.py +++ b/goodprice/analysis/json_extract.py @@ -5,8 +5,8 @@ 的括号与转义);比原先的 ``find("{")``/``rfind("}")`` 更稳:后者一旦输出里出现 多个对象、或字符串中含有大括号,就会取错范围。 -标准解析仍失败时,若安装了可选依赖 ``json-repair``,则交给它修复畸形或截断的 -JSON(例如输出被 max_tokens 截断)。 +标准解析仍失败时,交给 ``json-repair`` 修复畸形或截断的 JSON(例如输出被 +max_tokens 截断)。 """ from __future__ import annotations @@ -64,7 +64,7 @@ def _remove_trailing_commas(text: str) -> str: def _try_repair(text: str) -> dict[str, Any] | None: - """标准解析失败时,尝试用可选依赖 json-repair 修复畸形/截断的 JSON。""" + """标准解析失败时,尝试用 json-repair 修复畸形/截断的 JSON。""" try: from json_repair import repair_json except ImportError: diff --git a/goodprice/analysis/judge.py b/goodprice/analysis/judge.py index 4d610db..a4612f9 100644 --- a/goodprice/analysis/judge.py +++ b/goodprice/analysis/judge.py @@ -7,26 +7,29 @@ from goodprice.analysis.json_extract import extract_json_object from goodprice.analysis.llm import LLMClient +from goodprice.analysis.parse_utils import clamp_score +from goodprice.analysis.prompts import ( + REQUIREMENT_CRITERION, + REQUIREMENT_SYSTEM_PREFIX, + VALUE_CRITERION, +) +from goodprice.analysis.prompts import ( + REQUIREMENT_USER_TEMPLATE as REQUIREMENT_TYPED_USER_TEMPLATE, +) +from goodprice.constants import RISK_UNKNOWN logger = logging.getLogger(__name__) REQUIREMENT_TYPED_SYSTEM_PROMPT = ( - "你是二手商品筛选助手。用户给出商品标题、卖家描述和买家需求," - "请判断商品是否满足买家的硬性需求。只输出 JSON,不要输出其它文字,格式:" + f"{REQUIREMENT_SYSTEM_PREFIX}" + f"请判断{REQUIREMENT_CRITERION}。只输出 JSON,不要输出其它文字,格式:" '{"matched": true或false, "probability": 0到1的小数(该判断成立的概率,越接近1越确定), ' '"reason": "一句话理由"}' ) -REQUIREMENT_TYPED_USER_TEMPLATE = ( - "商品标题:{title}\n" - "卖家描述:{description}\n" - "买家需求:{requirement}\n" - "请给出 JSON 结论。" -) - VALUE_TYPED_SYSTEM_PROMPT = ( "你是二手商品性价比评估专家。用户给出单个商品(标题、价格、品相分、瑕疵、卖家风险)" - "与买家品相要求,请仅依据该商品本身独立评估按当前价格是否划算。只输出 JSON,不要输出其它文字,格式:" + f"与买家品相要求,请仅依据该商品本身独立评估{VALUE_CRITERION}。只输出 JSON,不要输出其它文字,格式:" '{"value_score": 1到10的整数(越高越划算), "probability": 0到1的小数(该评分的可信度), ' '"reason": "一句话理由"}' ) @@ -54,7 +57,7 @@ def parse_requirement_typed(raw: str) -> dict[str, Any]: def parse_value_typed(raw: str) -> dict[str, Any]: data = extract_json_object(raw) try: - score = max(1, min(10, int(data.get("value_score", 0)))) + score = clamp_score(data.get("value_score", 0)) except (TypeError, ValueError): raise ValueError(f"类型化性价比缺少整数 value_score: {raw!r}") from None return { @@ -127,7 +130,7 @@ def analyze_batch_value(self, items: list[dict[str, Any]]) -> dict[str, Any]: price=it.get("price"), condition_score=it.get("condition_score") or "未评估", defects="、".join(str(d) for d in (it.get("defects") or [])[:5]) or "无", - seller_risk=it.get("seller_risk") or "未知", + seller_risk=it.get("seller_risk") or RISK_UNKNOWN, ) try: data = self._ask(VALUE_TYPED_SYSTEM_PROMPT, text, parse_value_typed) @@ -151,8 +154,8 @@ def analyze_batch_value(self, items: list[dict[str, Any]]) -> dict[str, Any]: } def _ask(self, system: str, text: str, parser) -> dict[str, Any]: - payload = self.llm._payload([{"type": "text", "text": text}], system=system) - return self.llm._complete(payload, parser=parser) + payload = self.llm.payload([{"type": "text", "text": text}], system=system) + return self.llm.complete(payload, parser=parser) def build_judger( diff --git a/goodprice/analysis/llm.py b/goodprice/analysis/llm.py index 07389ed..b779102 100644 --- a/goodprice/analysis/llm.py +++ b/goodprice/analysis/llm.py @@ -6,6 +6,7 @@ import httpx from goodprice.analysis.json_extract import extract_json_object +from goodprice.analysis.parse_utils import clamp_score from goodprice.analysis.prompts import ( BATCH_VALUE_SYSTEM_PROMPT, BATCH_VALUE_USER_TEMPLATE, @@ -31,7 +32,7 @@ def parse_analysis_json(raw: str) -> dict[str, Any]: data = extract_json_object(raw) - score = max(1, min(10, int(data.get("condition_score", 0)))) + score = clamp_score(data.get("condition_score", 0)) defects = [str(d) for d in data.get("defects", [])][:10] return { "condition_score": score, @@ -61,7 +62,7 @@ def parse_batch_value_json(raw: str) -> dict[str, Any]: item_id = str(it.get("id", "")).strip() if not item_id: continue - score = max(1, min(10, int(it.get("value_score", 0)))) + score = clamp_score(it.get("value_score", 0)) scores[item_id] = score reasons[item_id] = str(it.get("reason", ""))[:200] best = str(data.get("best", "")).strip() @@ -156,7 +157,7 @@ def analyze_batch_value( raise RuntimeError("LLM 未配置") if not items: return {"scores": {}, "best": None, "reasons": {}} - requirement = str(items[0].get("requirement") or "") if items else "" + requirement = str(items[0].get("requirement") or "") lines = [] for i, it in enumerate(items, 1): defects = "、".join(str(d) for d in (it.get("defects") or [])[:5]) or "无" @@ -187,6 +188,14 @@ def _payload( "temperature": 0.2, } + def payload(self, content: list[dict[str, Any]], system: str = "") -> dict[str, Any]: + """公开请求体构造,供判断层/路由复用,无需访问私有 ``_payload``。""" + return self._payload(content, system=system) + + def complete(self, payload: dict[str, Any], parser=parse_analysis_json) -> dict[str, Any]: + """公开补全入口,按 ``api_format`` 自动选择 chat/responses。""" + return self._complete(payload, parser=parser) + def _complete( self, payload: dict[str, Any], parser=parse_analysis_json ) -> dict[str, Any]: diff --git a/goodprice/analysis/parse_utils.py b/goodprice/analysis/parse_utils.py new file mode 100644 index 0000000..1bd5833 --- /dev/null +++ b/goodprice/analysis/parse_utils.py @@ -0,0 +1,9 @@ +"""解析结果的通用规整工具,避免各解析器重复 clamp 逻辑。""" +from typing import Any + +from goodprice.constants import SCORE_MAX, SCORE_MIN + + +def clamp_score(value: Any) -> int: + """把模型返回的评分规整到 [SCORE_MIN, SCORE_MAX];非法值抛 ValueError。""" + return max(SCORE_MIN, min(SCORE_MAX, int(value))) diff --git a/goodprice/analysis/prompts.py b/goodprice/analysis/prompts.py index cf11eba..331aabf 100644 --- a/goodprice/analysis/prompts.py +++ b/goodprice/analysis/prompts.py @@ -1,3 +1,12 @@ +# 需求匹配与性价比的核心判据措辞:adapter(prompts/judge)与 typesafe +# (jev_typesafe)两个判断后端共用,避免同一判据在多处各写一份而漂移。 +REQUIREMENT_CRITERION = "商品是否满足买家的硬性需求" +VALUE_CRITERION = "按当前价格是否划算" + +# 需求判断系统提示前缀:adapter 与 typesafe 两个后端共用。 +REQUIREMENT_SYSTEM_PREFIX = "你是二手商品筛选助手。用户给出商品标题、卖家描述和买家需求," + + CONDITION_SYSTEM_PROMPT = ( "你是一位熟悉中国二手交易市场(闲鱼)的验货专家。用户给出商品标题、卖家描述和商品图片," "请只依据图片与卖家描述判断商品实际品相(成色),并判断品相是否与卖家描述一致。" @@ -16,8 +25,8 @@ ) REQUIREMENT_SYSTEM_PROMPT = ( - "你是二手商品筛选助手。用户给出商品标题、卖家描述和买家需求," - "请判断商品是否满足买家的硬性需求。只输出 JSON:" + f"{REQUIREMENT_SYSTEM_PREFIX}" + f"请判断{REQUIREMENT_CRITERION}。只输出 JSON:" '{"matched": true或false, "reason": "一句话理由"}' ) @@ -30,7 +39,8 @@ BATCH_VALUE_SYSTEM_PROMPT = ( "你是二手商品性价比分析专家。用户给出一批商品(标题、价格、品相分、瑕疵、卖家风险)," - "请在同一批内横向比较,判断每个商品按当前价格是否划算。只输出 JSON,不要输出其它文字,格式:" + "请在同一批内横向比较,判断每个商品" + f"{VALUE_CRITERION}。只输出 JSON,不要输出其它文字,格式:" '{"items": [{"id": "商品外部ID", "value_score": 1到10的整数(越高越划算), "reason": "一句话理由"}], ' '"best": "性价比最高商品的 id"}' ) diff --git a/goodprice/config.py b/goodprice/config.py index 8950023..feb741a 100644 --- a/goodprice/config.py +++ b/goodprice/config.py @@ -1,13 +1,38 @@ from functools import lru_cache from pathlib import Path +from pydantic import field_validator from pydantic_settings import BaseSettings, SettingsConfigDict PROJECT_ROOT = Path(__file__).resolve().parent.parent DEFAULT_DB = PROJECT_ROOT / "data" / "goodprice.db" +# 敏感字段:设置页留空表示“保持原值”。保存逻辑与模板提示共用这一份清单, +# 避免“留空白名单”在 routes.py 与 settings.html 两处各维护一份而漂移。 +SECRET_FIELDS: frozenset[str] = frozenset( + { + "llm_api_key", + "serverchan_sendkey", + "vision_api_key", + "wecom_webhook", + "feishu_webhook", + "feishu_secret", + "gotify_token", + "jev_api_key", + } +) + +# Jev 后端可选值:保存校验与设置页下拉共用。 +JEV_BACKENDS: tuple[str, ...] = ("adapter", "typesafe") + class Settings(BaseSettings): + """全部可配置字段的唯一事实来源。 + + 运行时值(SettingsService)与部署模板(.env.example)都以本类为准; + 新增字段后请同步 .env.example,tests/test_settings_consistency.py 会校验。 + """ + model_config = SettingsConfigDict( env_file=PROJECT_ROOT / ".env", env_file_encoding="utf-8", @@ -19,7 +44,7 @@ class Settings(BaseSettings): xianyu_cookie: str = "" llm_base_url: str = "" llm_api_key: str = "" - llm_model: str = "qwen-vl-max" + llm_model: str = "glm-4.7-flash" llm_api_format: str = "chat_completions" serverchan_sendkey: str = "" proxy: str = "" @@ -27,7 +52,7 @@ class Settings(BaseSettings): default_crawl_jitter_minutes: int = 10 vision_base_url: str = "" vision_api_key: str = "" - vision_model: str = "qwen-vl-max" + vision_model: str = "glm-4.6v-flash" vision_api_format: str = "chat_completions" wecom_webhook: str = "" feishu_webhook: str = "" @@ -43,11 +68,18 @@ class Settings(BaseSettings): runtime_mode: str = "local" enable_novnc: bool = False novnc_password_file: str = "/app/data/.novnc-password" + novnc_port: int = 16080 jev_enabled: bool = False jev_auto_threshold: float = 0.85 jev_backend: str = "adapter" jev_api_key: str = "" + @field_validator("jev_backend") + @classmethod + def _normalize_jev_backend(cls, value: str) -> str: + """非法后端名回退到默认值,校验逻辑集中在 schema 一处。""" + return value if value in JEV_BACKENDS else JEV_BACKENDS[0] + @lru_cache def get_settings() -> Settings: diff --git a/goodprice/constants.py b/goodprice/constants.py new file mode 100644 index 0000000..f813ac3 --- /dev/null +++ b/goodprice/constants.py @@ -0,0 +1,16 @@ +"""全局领域常量:评分刻度与卖家风险等级。 + +这些字面量原先散落在 analysis 各解析器、services 与 web 路由中;集中定义后 +Python 侧共用同一份(模板仍按值渲染)。 +""" + +SCORE_MIN = 1 +SCORE_MAX = 10 + +RISK_LOW = "低" +RISK_MEDIUM = "中" +RISK_HIGH = "高" +RISK_UNKNOWN = "未知" + +# 卖家风险 → 满意度权重系数(未知按中等处理) +RISK_FACTOR = {RISK_LOW: 1.0, RISK_MEDIUM: 0.5, RISK_HIGH: 0.0} diff --git a/goodprice/crawler/constants.py b/goodprice/crawler/constants.py new file mode 100644 index 0000000..b36c030 --- /dev/null +++ b/goodprice/crawler/constants.py @@ -0,0 +1,20 @@ +"""爬虫共用的网络常量。 + +UA 与闲鱼域名曾分别在 xianyu.py / login.py / parser.py 各写一份,这里集中定义, +避免升级浏览器指纹或换域名时漏改。 +""" + +USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" +) + +GOOFISH_ORIGIN = "https://www.goofish.com" +GOOFISH_HOME = f"{GOOFISH_ORIGIN}/" + +# Playwright 超时/等待(毫秒),原先散落在 xianyu.py 各处。 +NAV_TIMEOUT_MS = 45000 +SELECTOR_TIMEOUT_MS = 30000 +RENDER_SETTLE_MS = 3000 +SELLER_SETTLE_MS = 5000 +SELLER_DETAIL_SETTLE_MS = 4000 diff --git a/goodprice/crawler/login.py b/goodprice/crawler/login.py index 27946dd..131244c 100644 --- a/goodprice/crawler/login.py +++ b/goodprice/crawler/login.py @@ -7,15 +7,11 @@ from playwright.sync_api import sync_playwright from goodprice.config import PROJECT_ROOT +from goodprice.crawler.constants import GOOFISH_HOME, USER_AGENT logger = logging.getLogger(__name__) LOGIN_PROFILE_DIR = PROJECT_ROOT / "data" / "browser_profile" -GOOFISH_HOME = "https://www.goofish.com/" -USER_AGENT = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" -) # 淘宝系真正的登录态 cookie(未登录时不会出现);`t`/`cookie2` 匿名访问也会种,不能作为登录依据 LOGIN_COOKIES = ("unb", "sn", "uc1", "lgc") diff --git a/goodprice/crawler/parser.py b/goodprice/crawler/parser.py index ccbb7d1..99a1adf 100644 --- a/goodprice/crawler/parser.py +++ b/goodprice/crawler/parser.py @@ -6,8 +6,9 @@ from goodprice.crawler import selectors as sel from goodprice.crawler.base import ListingData, ListingDetail, SellerData +from goodprice.crawler.constants import GOOFISH_ORIGIN -BASE_URL = "https://www.goofish.com" +BASE_URL = GOOFISH_ORIGIN _PRICE_RE = re.compile(r"(\d+(?:\.\d+)?)") @@ -59,7 +60,6 @@ def parse_search_html(html: str, card_selector: str = sel.RESULT_CARD) -> list[L img_el = card.select_one(sel.IMAGE) src = img_el.get("src") if img_el else "" image_urls = [_absolute(src)] if src and is_product_image(src) else [] - seller_el = card.select_one(sel.SELLER) location_el = card.select_one(sel.LOCATION) items.append( ListingData( @@ -68,7 +68,7 @@ def parse_search_html(html: str, card_selector: str = sel.RESULT_CARD) -> list[L price=price, url=_absolute(href), image_urls=[_absolute(u) for u in image_urls], - seller=seller_el.get_text(strip=True) if seller_el else None, + seller=None, location=location_el.get_text(strip=True) if location_el else None, ) ) @@ -106,7 +106,7 @@ def parse_detail_html(html: str) -> ListingDetail: images.sort(key=lambda u: 0 if "xy_item" in u else 1) # 主图优先 seller_link = soup.select_one(sel.DETAIL_SELLER_LINK) seller_uid = extract_user_id(seller_link.get("href")) if seller_link else None - seller_name, positive_rate, sold_count, _ = _parse_seller_block(seller_link) + seller_name, positive_rate, sold_count = _parse_seller_block(seller_link) nick_el = soup.select_one(sel.DETAIL_SELLER_NICK) if nick_el: seller_name = nick_el.get_text(strip=True) or seller_name @@ -134,9 +134,11 @@ def is_product_image(url: str) -> bool: return "bao/uploaded" in (url or "") -def _parse_seller_block(seller_link): +def _parse_seller_block( + seller_link, +) -> tuple[Optional[str], Optional[float], Optional[int]]: if seller_link is None: - return None, None, None, None + return None, None, None block_text = seller_link.get_text(" ", strip=True) first_line = seller_link.get_text("\n", strip=True).splitlines() name = first_line[0] if first_line else None @@ -148,7 +150,7 @@ def _parse_seller_block(seller_link): m = re.search(r"卖出\s*(\d+)\s*件", block_text) if m: sold_count = int(m.group(1)) - return name, positive_rate, sold_count, block_text + return name, positive_rate, sold_count _TAG_NAMES = ("沟通愉快", "收货快", "回复快", "下单爽快", "描述真实", "发货快") diff --git a/goodprice/crawler/selectors.py b/goodprice/crawler/selectors.py index 47c46b5..32d38d1 100644 --- a/goodprice/crawler/selectors.py +++ b/goodprice/crawler/selectors.py @@ -7,7 +7,7 @@ TITLE = "[class*='main-title--']" PRICE = "[class*='price-wrap--']" IMAGE = "img[class*='feeds-image--']" -SELLER = "[class*='seller-text--']" +# 搜索结果卡片只有地区文本(seller-text--),不含卖家昵称;昵称从详情页解析。 LOCATION = "[class*='seller-text--']" # 商品详情页(实测:描述 span[class*='desc--'],主图 img.ant-image-img) diff --git a/goodprice/crawler/xianyu.py b/goodprice/crawler/xianyu.py index 3818ac5..f1d7164 100644 --- a/goodprice/crawler/xianyu.py +++ b/goodprice/crawler/xianyu.py @@ -1,3 +1,4 @@ +from contextlib import contextmanager from typing import Callable, Optional from urllib.parse import quote @@ -5,15 +6,20 @@ from goodprice.crawler import selectors as sel from goodprice.crawler.base import CrawlerAuthError, ListingData, ListingDetail, SellerData +from goodprice.crawler.constants import ( + GOOFISH_ORIGIN, + NAV_TIMEOUT_MS, + RENDER_SETTLE_MS, + SELECTOR_TIMEOUT_MS, + SELLER_DETAIL_SETTLE_MS, + SELLER_SETTLE_MS, + USER_AGENT, +) from goodprice.crawler.parser import parse_detail_html, parse_search_html, parse_seller_html -SEARCH_URL = "https://www.goofish.com/search?q={keyword}&spm=a21ybx.search.searchInput.0" +SEARCH_URL = f"{GOOFISH_ORIGIN}/search?q={{keyword}}&spm=a21ybx.search.searchInput.0" SEARCH_ATTEMPTS = 3 SEARCH_ATTEMPT_GAP_MS = 5000 -USER_AGENT = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" -) class XianyuAdapter: @@ -42,11 +48,9 @@ def _cookies(self) -> dict[str, str]: result[key.strip()] = value.strip() return result - def search( - self, keyword: str, max_items: int = 30, attempts: int = SEARCH_ATTEMPTS - ) -> list[ListingData]: - """搜索并合并多次尝试的结果(闲鱼后端间歇性返回不同结果集,重试可提高命中率)。""" - seen: dict[str, ListingData] = {} + @contextmanager + def _open_page(self): + """统一的浏览器/上下文/Cookie 装配,三处抓取复用。""" with self._playwright_factory() as playwright: browser = playwright.chromium.launch( headless=self.headless, @@ -60,95 +64,72 @@ def search( for k, v in self._cookies().items() ] ) - page = context.new_page() - url = SEARCH_URL.format(keyword=quote(keyword)) - for attempt in range(max(1, attempts)): - if attempt: - page.wait_for_timeout(SEARCH_ATTEMPT_GAP_MS) - page.goto(url, wait_until="domcontentloaded", timeout=45000) - if "login" in (page.url or ""): - raise CrawlerAuthError("闲鱼 Cookie 已失效或未登录,请重新获取") - card_selector = sel.RESULT_CARD - try: - page.wait_for_selector(card_selector, timeout=30000) - except Exception as exc: - if page.locator(sel.RESULT_CARD_FALLBACK).count() > 0: - card_selector = sel.RESULT_CARD_FALLBACK - else: - body_text = "" - try: - body_text = page.inner_text("body")[:300] - except Exception: - pass - if "加载中" in body_text: - raise CrawlerAuthError( - "搜索结果一直显示加载中,Cookie 可能已失效或未登录,请重新获取" - ) from exc - raise RuntimeError( - f"未在页面中找到商品卡片,页面可能改版或触发风控。页面摘要: {body_text[:150]}" - ) from exc - # 等待真实结果渲染(刚出现卡片时可能只是占位/推荐位) - page.wait_for_timeout(3000) - for item in parse_search_html(page.content(), card_selector=card_selector): - seen.setdefault(item.external_id, item) - if len(seen) >= max_items: - break - return list(seen.values())[:max_items] + yield context.new_page() finally: browser.close() - def fetch_detail(self, url: str) -> ListingDetail: - with self._playwright_factory() as playwright: - browser = playwright.chromium.launch( - headless=self.headless, - proxy={"server": self.proxy} if self.proxy else None, - ) - try: - context = browser.new_context(user_agent=USER_AGENT) - context.add_cookies( - [ - {"name": k, "value": v, "domain": ".goofish.com", "path": "/"} - for k, v in self._cookies().items() - ] - ) - page = context.new_page() - page.goto(url, wait_until="domcontentloaded", timeout=45000) + def search( + self, keyword: str, max_items: int = 30, attempts: int = SEARCH_ATTEMPTS + ) -> list[ListingData]: + """搜索并合并多次尝试的结果(闲鱼后端间歇性返回不同结果集,重试可提高命中率)。""" + seen: dict[str, ListingData] = {} + with self._open_page() as page: + url = SEARCH_URL.format(keyword=quote(keyword)) + for attempt in range(max(1, attempts)): + if attempt: + page.wait_for_timeout(SEARCH_ATTEMPT_GAP_MS) + page.goto(url, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS) if "login" in (page.url or ""): raise CrawlerAuthError("闲鱼 Cookie 已失效或未登录,请重新获取") + card_selector = sel.RESULT_CARD try: - page.wait_for_selector(sel.DETAIL_DESC, timeout=30000) - except Exception: - pass # 描述缺失时仍解析 - return parse_detail_html(page.content()) - finally: - browser.close() + page.wait_for_selector(card_selector, timeout=SELECTOR_TIMEOUT_MS) + except Exception as exc: + if page.locator(sel.RESULT_CARD_FALLBACK).count() > 0: + card_selector = sel.RESULT_CARD_FALLBACK + else: + body_text = "" + try: + body_text = page.inner_text("body")[:300] + except Exception: + pass + if "加载中" in body_text: + raise CrawlerAuthError( + "搜索结果一直显示加载中,Cookie 可能已失效或未登录,请重新获取" + ) from exc + raise RuntimeError( + f"未在页面中找到商品卡片,页面可能改版或触发风控。页面摘要: {body_text[:150]}" + ) from exc + # 等待真实结果渲染(刚出现卡片时可能只是占位/推荐位) + page.wait_for_timeout(RENDER_SETTLE_MS) + for item in parse_search_html(page.content(), card_selector=card_selector): + seen.setdefault(item.external_id, item) + if len(seen) >= max_items: + break + return list(seen.values())[:max_items] + + def fetch_detail(self, url: str) -> ListingDetail: + with self._open_page() as page: + page.goto(url, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS) + if "login" in (page.url or ""): + raise CrawlerAuthError("闲鱼 Cookie 已失效或未登录,请重新获取") + try: + page.wait_for_selector(sel.DETAIL_DESC, timeout=SELECTOR_TIMEOUT_MS) + except Exception: + pass # 描述缺失时仍解析 + return parse_detail_html(page.content()) def fetch_seller(self, user_id: str) -> SellerData: - with self._playwright_factory() as playwright: - browser = playwright.chromium.launch( - headless=self.headless, - proxy={"server": self.proxy} if self.proxy else None, + with self._open_page() as page: + url = f"{GOOFISH_ORIGIN}/personal?userId={quote(user_id)}" + page.goto(url, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS) + if "login" in (page.url or ""): + raise CrawlerAuthError("闲鱼 Cookie 已失效或未登录,请重新获取") + page.wait_for_timeout(SELLER_SETTLE_MS) + page.evaluate( + "() => { const re = /信用及评价/; " + "const el = [...document.querySelectorAll('*')].find(e => e.children.length === 0 && re.test(e.textContent)); " + "if (el) { el.click(); return true; } return false; }" ) - try: - context = browser.new_context(user_agent=USER_AGENT) - context.add_cookies( - [ - {"name": k, "value": v, "domain": ".goofish.com", "path": "/"} - for k, v in self._cookies().items() - ] - ) - page = context.new_page() - url = f"https://www.goofish.com/personal?userId={quote(user_id)}" - page.goto(url, wait_until="domcontentloaded", timeout=45000) - if "login" in (page.url or ""): - raise CrawlerAuthError("闲鱼 Cookie 已失效或未登录,请重新获取") - page.wait_for_timeout(5000) - page.evaluate( - "() => { const re = /信用及评价/; " - "const el = [...document.querySelectorAll('*')].find(e => e.children.length === 0 && re.test(e.textContent)); " - "if (el) { el.click(); return true; } return false; }" - ) - page.wait_for_timeout(4000) - return parse_seller_html(page.content(), user_id) - finally: - browser.close() + page.wait_for_timeout(SELLER_DETAIL_SETTLE_MS) + return parse_seller_html(page.content(), user_id) diff --git a/goodprice/db.py b/goodprice/db.py index b2c9032..385a3c3 100644 --- a/goodprice/db.py +++ b/goodprice/db.py @@ -3,14 +3,6 @@ from sqlalchemy import create_engine, text from sqlalchemy.orm import DeclarativeBase, sessionmaker -_LISTINGS_COLUMNS = ( - "id, platform, external_id, title, price, url, image_urls, seller, location, " - "published_at, first_seen_at, last_seen_at, condition_score, condition_detail, " - "notified_at, description, requirement_match, requirement_reason, seller_uid, " - "seller_name, seller_risk, blocked, satisfaction, status, missed_count, variants, " - "value_score, value_batch_at, best_of_batch, last_notified_satisfaction, task_id" -) - _LISTINGS_DDL = """ id INTEGER PRIMARY KEY, platform VARCHAR(50) NOT NULL, @@ -148,7 +140,7 @@ def _listing_unique_columns(session): def _rebuild_listings(session) -> None: """重建 listings 表:把全局唯一(platform, external_id)换成按任务唯一(platform, external_id, task_id)。""" old_cols = [r[1] for r in session.execute(text("PRAGMA table_info('listings')"))] - new_cols = [c.strip() for c in _LISTINGS_COLUMNS.split(",")] + new_cols = list(_COLUMN_DDL) # 列清单由 DDL 派生,避免再维护一份 _LISTINGS_COLUMNS # 老表缺的列先以可空形式补上,保证拷贝时列齐全 for col in new_cols: if col == "id" or col in old_cols: @@ -174,6 +166,8 @@ def select_expr(col: str) -> str: _COLUMN_DDL: dict[str, str] = {} +# 重建表用的列定义从 _LISTINGS_DDL 解析而来;测试保证其与 Listing 模型列一致 +# (tests/test_db.py::test_listings_ddl_covers_model_columns)。 for _line in _LISTINGS_DDL.strip().splitlines(): _line = _line.strip().rstrip(",") if not _line or _line.startswith("CONSTRAINT") or _line.startswith("FOREIGN"): diff --git a/goodprice/main.py b/goodprice/main.py index 81d9566..7f2bc09 100644 --- a/goodprice/main.py +++ b/goodprice/main.py @@ -1,5 +1,5 @@ import logging -import sys +import os from contextlib import asynccontextmanager from logging.handlers import RotatingFileHandler from pathlib import Path @@ -31,7 +31,7 @@ def _setup_logging() -> None: console = logging.StreamHandler() console.setFormatter(formatter) root.addHandler(console) - if "pytest" not in sys.modules: + if not os.environ.get("PYTEST_CURRENT_TEST"): file_handler = RotatingFileHandler( LOG_DIR / "app.log", maxBytes=5 * 1024 * 1024, @@ -50,11 +50,8 @@ def _make_crawl_service(session_factory, settings_service, guard): runtime = settings_service.get() from goodprice.analysis.llm import LLMClient from goodprice.crawler.xianyu import XianyuAdapter - from goodprice.notify.feishu import FeishuNotifier - from goodprice.notify.gotify import GotifyNotifier from goodprice.notify.log import LogNotifier - from goodprice.notify.serverchan import ServerChanNotifier - from goodprice.notify.wecom_robot import WeComRobotNotifier + from goodprice.notify.registry import build_enabled_notifiers adapter = XianyuAdapter(cookie=runtime.xianyu_cookie, proxy=runtime.proxy) seller_service = SellerService(session_factory, adapter=adapter) @@ -75,30 +72,7 @@ def _make_crawl_service(session_factory, settings_service, guard): if runtime.vision_enabled else LLMClient(base_url="", api_key="", model="") ) - notifiers = [("log", LogNotifier())] - if runtime.serverchan_enabled: - serverchan = ServerChanNotifier(sendkey=runtime.serverchan_sendkey) - if serverchan.enabled: - notifiers.append(("serverchan", serverchan)) - if runtime.wecom_robot_enabled: - robot = WeComRobotNotifier(webhook=runtime.wecom_webhook) - if robot.enabled: - notifiers.append(("wecom_robot", robot)) - if runtime.feishu_enabled: - feishu = FeishuNotifier( - webhook=runtime.feishu_webhook, - secret=runtime.feishu_secret, - ) - if feishu.enabled: - notifiers.append(("feishu", feishu)) - if runtime.gotify_enabled: - gotify = GotifyNotifier( - url=runtime.gotify_url, - token=runtime.gotify_token, - priority=runtime.gotify_priority, - ) - if gotify.enabled: - notifiers.append(("gotify", gotify)) + notifiers = [("log", LogNotifier()), *build_enabled_notifiers(runtime)] return CrawlService( session_factory=session_factory, adapter=adapter, diff --git a/goodprice/models.py b/goodprice/models.py index d714b34..cedc986 100644 --- a/goodprice/models.py +++ b/goodprice/models.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional +from typing import Any, Optional from sqlalchemy import ( JSON, @@ -54,26 +54,26 @@ class Listing(Base): title: Mapped[str] = mapped_column(String(500)) price: Mapped[float] = mapped_column(Float) url: Mapped[str] = mapped_column(Text) - image_urls: Mapped[list] = mapped_column(JSON, default=list) + image_urls: Mapped[list[str]] = mapped_column(JSON, default=list) seller: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) location: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) published_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) first_seen_at: Mapped[datetime] = mapped_column(DateTime, default=_now) last_seen_at: Mapped[datetime] = mapped_column(DateTime, default=_now) condition_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) - condition_detail: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + condition_detail: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True) notified_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) requirement_match: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True) requirement_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True) seller_uid: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) seller_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) - seller_risk: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + seller_risk: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True) blocked: Mapped[bool] = mapped_column(default=False) satisfaction: Mapped[float] = mapped_column(Float, default=0.0) status: Mapped[str] = mapped_column(String(20), default="active") missed_count: Mapped[int] = mapped_column(Integer, default=0) - variants: Mapped[list] = mapped_column(JSON, default=list) + variants: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list) value_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) value_batch_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) best_of_batch: Mapped[bool] = mapped_column(default=False) @@ -141,7 +141,7 @@ class Seller(Base): credit_label: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) positive_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) total_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) - tags: Mapped[Optional[list]] = mapped_column(JSON, nullable=True) + tags: Mapped[Optional[list[str]]] = mapped_column(JSON, nullable=True) positive_rate: Mapped[Optional[float]] = mapped_column(Float, nullable=True) last_fetched_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) blocked: Mapped[bool] = mapped_column(default=False) diff --git a/goodprice/notify/registry.py b/goodprice/notify/registry.py new file mode 100644 index 0000000..24ab99f --- /dev/null +++ b/goodprice/notify/registry.py @@ -0,0 +1,72 @@ +"""通知渠道的唯一注册表。 + +渠道名、启用开关字段与构造逻辑集中在此处:main.py(运行时装配)与 +web/routes.py(测试发送)都从这里取,避免渠道名在多个文件里平行维护而漂移。 +新增渠道只需在此追加一条 ChannelSpec,并同步 settings.html 与 Settings 字段。 +""" +from dataclasses import dataclass +from typing import Callable + +from goodprice.config import Settings +from goodprice.notify.base import Notifier +from goodprice.notify.feishu import FeishuNotifier +from goodprice.notify.gotify import GotifyNotifier +from goodprice.notify.serverchan import ServerChanNotifier +from goodprice.notify.wecom_robot import WeComRobotNotifier + + +@dataclass(frozen=True) +class ChannelSpec: + name: str + enabled_field: str + build: Callable[[Settings], Notifier] + + +CHANNELS: tuple[ChannelSpec, ...] = ( + ChannelSpec( + "serverchan", + "serverchan_enabled", + lambda s: ServerChanNotifier(sendkey=s.serverchan_sendkey), + ), + ChannelSpec( + "wecom_robot", + "wecom_robot_enabled", + lambda s: WeComRobotNotifier(webhook=s.wecom_webhook), + ), + ChannelSpec( + "feishu", + "feishu_enabled", + lambda s: FeishuNotifier(webhook=s.feishu_webhook, secret=s.feishu_secret), + ), + ChannelSpec( + "gotify", + "gotify_enabled", + lambda s: GotifyNotifier( + url=s.gotify_url, token=s.gotify_token, priority=s.gotify_priority + ), + ), +) + + +def channel_names() -> set[str]: + return {spec.name for spec in CHANNELS} + + +def build_channel(name: str, settings: Settings) -> Notifier | None: + """按渠道名构造通知器;未知渠道返回 None。""" + for spec in CHANNELS: + if spec.name == name: + return spec.build(settings) + return None + + +def build_enabled_notifiers(settings: Settings) -> list[tuple[str, Notifier]]: + """返回所有已启用且配置完整的 (渠道名, 通知器),供运行时装配使用。""" + notifiers: list[tuple[str, Notifier]] = [] + for spec in CHANNELS: + if not getattr(settings, spec.enabled_field): + continue + notifier = spec.build(settings) + if notifier.enabled: + notifiers.append((spec.name, notifier)) + return notifiers diff --git a/goodprice/notify/serverchan.py b/goodprice/notify/serverchan.py index 8707399..3c3a3db 100644 --- a/goodprice/notify/serverchan.py +++ b/goodprice/notify/serverchan.py @@ -27,12 +27,12 @@ def enabled(self) -> bool: def send(self, message: NotificationMessage) -> None: if not self.enabled: raise RuntimeError("Server酱未配置 sendkey") - client = httpx.Client(transport=self._transport, timeout=self.timeout) - response = client.post( - SERVERCHAN_URL.format(key=self.sendkey), - data={"title": message.title, "desp": f"{message.content}\n{message.url}"}, - ) - response.raise_for_status() - data = response.json() + with httpx.Client(transport=self._transport, timeout=self.timeout) as client: + response = client.post( + SERVERCHAN_URL.format(key=self.sendkey), + data={"title": message.title, "desp": f"{message.content}\n{message.url}"}, + ) + response.raise_for_status() + data = response.json() if data.get("code") != 0: raise RuntimeError(f"Server酱返回错误: {data}") diff --git a/goodprice/notify/wecom_robot.py b/goodprice/notify/wecom_robot.py index 684b066..dc88c1f 100644 --- a/goodprice/notify/wecom_robot.py +++ b/goodprice/notify/wecom_robot.py @@ -26,12 +26,12 @@ def send(self, message: NotificationMessage) -> None: if not self.enabled: raise RuntimeError("企业微信群机器人未配置 webhook") content = f"{message.title}\n{message.content}\n{message.url}" - client = httpx.Client(transport=self._transport, timeout=self.timeout) - response = client.post( - self.webhook, json={"msgtype": "text", "text": {"content": content}} - ) - response.raise_for_status() - data = response.json() + with httpx.Client(transport=self._transport, timeout=self.timeout) as client: + response = client.post( + self.webhook, json={"msgtype": "text", "text": {"content": content}} + ) + response.raise_for_status() + data = response.json() errcode = data.get("errcode", -1) if errcode == 93000: raise RuntimeError("企业微信群机器人 webhook 无效或机器人已被移除,请重新添加") diff --git a/goodprice/services/crawl_service.py b/goodprice/services/crawl_service.py index 0677dde..a305eab 100644 --- a/goodprice/services/crawl_service.py +++ b/goodprice/services/crawl_service.py @@ -10,6 +10,7 @@ from goodprice.models import Listing, Notification, PriceSnapshot, WatchTask from goodprice.notify.base import NotificationMessage from goodprice.services.satisfaction import ( + DROP_PCT_DISPLAY_THRESHOLD, compute_satisfaction, drop_pct_from_snapshots, ) @@ -143,37 +144,24 @@ def _run_impl(self, task_id: int) -> dict[str, Any]: logger.info("任务 %s 新品 %s:%s ¥%s", task_id, data.external_id, data.title[:30], data.price) if task.fetch_detail: self._fetch_detail(session, listing) - if not self._requirement_pass(session, listing, task): - logger.info("任务 %s 需求不匹配,不收录:%s", task_id, listing.title[:30]) - session.commit() - continue - self._condition_analysis(session, listing, task) - if self._condition_gate_fails(task, listing): - logger.info("任务 %s 品相分低于门槛,不收录:%s", task_id, listing.title[:30]) + reason = self._evaluate_and_queue( + session, task, listing, old_price, batch_rows, pending, False + ) + if reason: + logger.info("任务 %s %s,不收录:%s", task_id, reason, listing.title[:30]) session.commit() continue - self._seller_check(session, listing, task) - batch_rows.append( - self._batch_row(listing, task.condition_requirement or "") - ) - pending.append((task, listing, old_price, False)) else: changed = old_price is not None or listing.status == "gone" if changed: stats["reevaluated"] += 1 logger.info("任务 %s 重评 %s:%s(价格变化或重新上架)", task_id, data.external_id, data.title[:30]) - if not self._requirement_pass(session, listing, task): - session.commit() - continue - self._condition_analysis(session, listing, task) - if self._condition_gate_fails(task, listing): + reason = self._evaluate_and_queue( + session, task, listing, old_price, batch_rows, pending, True + ) + if reason: session.commit() continue - self._seller_check(session, listing, task) - batch_rows.append( - self._batch_row(listing, task.condition_requirement or "") - ) - pending.append((task, listing, old_price, True)) else: if self._backfill(session, listing, task): stats["backfilled"] += 1 @@ -221,6 +209,27 @@ def _run_impl(self, task_id: int) -> dict[str, Any]: session.commit() return stats + def _evaluate_and_queue( + self, + session, + task: WatchTask, + listing: Listing, + old_price: Optional[float], + batch_rows: list[dict], + pending: list[tuple], + is_renotify: bool, + ) -> Optional[str]: + """需求→品相→门槛→卖家评估并入队;不通过时返回原因(调用方需 commit 后跳过)。""" + if not self._requirement_pass(session, listing, task): + return "需求不匹配" + self._condition_analysis(session, listing, task) + if self._condition_gate_fails(task, listing): + return "品相分低于门槛" + self._seller_check(session, listing, task) + batch_rows.append(self._batch_row(listing, task.condition_requirement or "")) + pending.append((task, listing, old_price, is_renotify)) + return None + def _upsert_listing(self, session, task: WatchTask, data: ListingData): listing = ( session.query(Listing) @@ -412,10 +421,12 @@ def _build_judger(self, settings): api_key=getattr(settings, "jev_api_key", "") or "", ) if judger is not None and not judger.enabled: + backend_name = getattr(settings, "jev_backend", "adapter") or "adapter" + reason = "缺少 TypeSafe API Key" if backend_name == "typesafe" else "LLM 未配置" logger.warning( "Jev 判断层已开启但后端不可用(backend=%s:%s),将完全走原模型路径", - getattr(settings, "jev_backend", "adapter"), - "缺少 TypeSafe API Key" if judger is not None and hasattr(judger, "api_key") else "LLM 未配置", + backend_name, + reason, ) return judger @@ -592,7 +603,7 @@ def _fmt(value: float) -> str: lines = [f"价格:{_fmt(listing.price)} 元"] drop_pct = self._drop_pct(listing) - if drop_pct >= 0.05: + if drop_pct >= DROP_PCT_DISPLAY_THRESHOLD: lines.append(f"较首见降价 {drop_pct:.0%}") if is_renotify and old_price is not None: lines.append(f"价格更新重推:{_fmt(old_price)} → {_fmt(listing.price)} 元") diff --git a/goodprice/services/satisfaction.py b/goodprice/services/satisfaction.py index aa0165c..3244b29 100644 --- a/goodprice/services/satisfaction.py +++ b/goodprice/services/satisfaction.py @@ -1,3 +1,9 @@ +from goodprice.constants import RISK_FACTOR, RISK_MEDIUM, SCORE_MAX + +# 展示“较首见降价”的统一阈值:通知文案与详情页徽标共用,避免两处各写 0.05。 +DROP_PCT_DISPLAY_THRESHOLD = 0.05 + + def _drop_bonus(pct: float) -> float: """降价加成:较首见降幅 ≥8% 加 1 档,≥20% 加 2 档(折算进性价比维度)。""" if pct >= 0.20: @@ -23,16 +29,16 @@ def compute_satisfaction( score += req_w / 2 if cond_w: if listing.condition_score is not None: - score += min(cond_w, listing.condition_score * cond_w / 10) + score += min(cond_w, listing.condition_score * cond_w / SCORE_MAX) else: score += cond_w / 2 value_base = listing.value_score if listing.value_score is not None else 5.0 - value_eff = min(10.0, value_base + _drop_bonus(price_drop_pct)) - score += min(value_w, value_eff * value_w / 10) + value_eff = min(float(SCORE_MAX), value_base + _drop_bonus(price_drop_pct)) + score += min(value_w, value_eff * value_w / SCORE_MAX) risk = None if isinstance(listing.seller_risk, dict): risk = listing.seller_risk.get("risk_level") - score += {"低": seller_w, "中": seller_w / 2, "高": 0.0}.get(risk, seller_w / 2) + score += seller_w * RISK_FACTOR.get(risk, RISK_FACTOR[RISK_MEDIUM]) return score diff --git a/goodprice/services/seller_service.py b/goodprice/services/seller_service.py index b5d935e..4ea2216 100644 --- a/goodprice/services/seller_service.py +++ b/goodprice/services/seller_service.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta from typing import Optional +from goodprice.constants import RISK_HIGH, RISK_LOW, RISK_MEDIUM, RISK_UNKNOWN from goodprice.models import Seller logger = logging.getLogger(__name__) @@ -12,31 +13,31 @@ def compute_risk( seller: Optional[Seller], credit_label: Optional[str] = None, detail_rate: Optional[float] = None, -): +) -> tuple[str, str]: """返回 (风险等级, 一句话理由)。只提示不拦截。""" rate = detail_rate if detail_rate is not None else (seller.positive_rate if seller else None) label = credit_label or getattr(seller, "credit_label", None) or "" if rate is not None: pct = rate * 100 if rate >= 0.98: - return "低", f"好评率 {pct:.0f}%" + return RISK_LOW, f"好评率 {pct:.0f}%" if rate >= 0.90: - return "中", f"好评率 {pct:.0f}%" - return "高", f"好评率 {pct:.0f}%" + return RISK_MEDIUM, f"好评率 {pct:.0f}%" + return RISK_HIGH, f"好评率 {pct:.0f}%" if label: if "极好" in label: - return "低", label + return RISK_LOW, label if "良好" in label or label.endswith("好"): - return "中", label - return "高", label + return RISK_MEDIUM, label + return RISK_HIGH, label if seller and seller.positive_count is not None and seller.total_count: pct = seller.positive_count / seller.total_count * 100 if pct >= 98: - return "低", f"好评 {seller.positive_count}/{seller.total_count}" + return RISK_LOW, f"好评 {seller.positive_count}/{seller.total_count}" if pct >= 90: - return "中", f"好评 {seller.positive_count}/{seller.total_count}" - return "高", f"好评 {seller.positive_count}/{seller.total_count}" - return "未知", "卖家数据不足" + return RISK_MEDIUM, f"好评 {seller.positive_count}/{seller.total_count}" + return RISK_HIGH, f"好评 {seller.positive_count}/{seller.total_count}" + return RISK_UNKNOWN, "卖家数据不足" class SellerService: diff --git a/goodprice/services/settings_service.py b/goodprice/services/settings_service.py index 3634c1b..028f040 100644 --- a/goodprice/services/settings_service.py +++ b/goodprice/services/settings_service.py @@ -1,71 +1,12 @@ -from dataclasses import asdict, dataclass from typing import Optional from goodprice.config import Settings from goodprice.models import AppSetting - -@dataclass -class RuntimeSettings: - _INT_FIELDS = { - "default_crawl_interval_minutes", - "default_crawl_jitter_minutes", - "gotify_priority", - } - _FLOAT_FIELDS = {"jev_auto_threshold"} - _BOOL_FIELDS = { - "serverchan_enabled", - "wecom_robot_enabled", - "feishu_enabled", - "gotify_enabled", - "vision_enabled", - "jev_enabled", - } - - xianyu_cookie: str = "" - llm_base_url: str = "" - llm_api_key: str = "" - llm_model: str = "" - llm_api_format: str = "chat_completions" - serverchan_sendkey: str = "" - proxy: str = "" - default_crawl_interval_minutes: int = 20 - default_crawl_jitter_minutes: int = 10 - vision_base_url: str = "" - vision_api_key: str = "" - vision_model: str = "" - vision_api_format: str = "chat_completions" - wecom_webhook: str = "" - feishu_webhook: str = "" - feishu_secret: str = "" - feishu_enabled: bool = True - gotify_url: str = "" - gotify_token: str = "" - gotify_priority: int = 5 - gotify_enabled: bool = True - serverchan_enabled: bool = True - wecom_robot_enabled: bool = True - vision_enabled: bool = True - jev_enabled: bool = False - jev_auto_threshold: float = 0.85 - jev_backend: str = "adapter" - jev_api_key: str = "" - - @classmethod - def from_sources(cls, base: Settings, overrides: dict[str, str]) -> "RuntimeSettings": - values = asdict(cls()) - values.update({k: v for k, v in base.model_dump().items() if k in values}) - values.update({k: v for k, v in overrides.items() if v != "" and k in values}) - for key in cls._INT_FIELDS: - if values.get(key) not in ("", None): - values[key] = int(values[key]) - for key in cls._FLOAT_FIELDS: - if values.get(key) not in ("", None): - values[key] = float(values[key]) - for key in cls._BOOL_FIELDS: - if values.get(key) not in ("", None): - values[key] = str(values[key]).lower() in ("1", "true", "yes", "on") - return cls(**values) +# 设置字段只在 goodprice.config.Settings 定义一次。运行时值与数据库覆盖都经同一个 +# pydantic 模型校验与类型转换(int/float/bool 由字段类型自动完成),不再维护第二份 +# dataclass 字段副本和手写的类型集合,避免两处字段/类型漂移。 +RuntimeSettings = Settings class SettingsService: @@ -76,9 +17,16 @@ def __init__(self, session_factory, base: Optional[Settings] = None): def _overrides(self, session) -> dict[str, str]: return {row.key: row.value for row in session.query(AppSetting).all()} + def _merge(self, overrides: dict[str, str]) -> RuntimeSettings: + values = self._base.model_dump() + for key, value in overrides.items(): + if key in values and value != "": + values[key] = value + return Settings(_env_file=None, **values) + def get(self) -> RuntimeSettings: with self._session_factory() as session: - return RuntimeSettings.from_sources(self._base, self._overrides(session)) + return self._merge(self._overrides(session)) def set_many(self, values: dict[str, str]) -> RuntimeSettings: with self._session_factory() as session: @@ -92,4 +40,4 @@ def set_many(self, values: dict[str, str]) -> RuntimeSettings: else: session.add(AppSetting(key=key, value=value)) session.commit() - return RuntimeSettings.from_sources(self._base, self._overrides(session)) + return self._merge(self._overrides(session)) diff --git a/goodprice/services/task_service.py b/goodprice/services/task_service.py index 5dd35ae..84c3369 100644 --- a/goodprice/services/task_service.py +++ b/goodprice/services/task_service.py @@ -3,6 +3,7 @@ import math from typing import Optional +from goodprice.constants import SCORE_MAX from goodprice.models import WatchTask @@ -30,8 +31,8 @@ def number(name: str) -> float: interval_minutes = int(20 if interval_value in (None, "") else interval_value) except (TypeError, ValueError) as exc: raise ValueError("品相分数和抓取间隔必须是整数") from exc - if not 0 <= min_condition_score <= 10: - raise ValueError("最低品相分必须在 0 到 10 之间") + if not 0 <= min_condition_score <= SCORE_MAX: + raise ValueError(f"最低品相分必须在 0 到 {SCORE_MAX} 之间") if interval_minutes < 1: raise ValueError("抓取间隔不能小于 1 分钟") @@ -51,6 +52,36 @@ def number(name: str) -> float: } +# 任务可编辑字段的唯一清单:创建、更新、API 序列化共用,避免多处平行维护而脱节。 +TASK_FIELDS: tuple[str, ...] = ( + "name", + "keyword", + "max_price", + "min_price", + "exclude_words", + "condition_requirement", + "min_condition_score", + "platform", + "interval_minutes", + "fetch_detail", + "enabled", +) + + +def task_to_dict(task) -> dict: + """任务 API 输出:可编辑字段由 TASK_FIELDS 派生,杜绝与表单/创建参数脱节。""" + data = {field: getattr(task, field) for field in TASK_FIELDS} + data.update( + { + "id": task.id, + "last_run_at": task.last_run_at.isoformat() if task.last_run_at else None, + "last_error": task.last_error, + "last_run_count": task.last_run_count, + } + ) + return data + + class TaskService: def __init__(self, session_factory): self._session_factory = session_factory @@ -65,19 +96,7 @@ def get_task(self, task_id: int) -> Optional[WatchTask]: def create_task(self, data: dict) -> WatchTask: values = normalize_task_data(data) - task = WatchTask( - name=values["name"], - keyword=values["keyword"], - max_price=values["max_price"], - min_price=values["min_price"], - exclude_words=values["exclude_words"], - condition_requirement=values["condition_requirement"], - min_condition_score=values["min_condition_score"], - platform=values["platform"], - interval_minutes=values["interval_minutes"], - fetch_detail=values["fetch_detail"], - enabled=values["enabled"], - ) + task = WatchTask(**{field: values[field] for field in TASK_FIELDS}) with self._session_factory() as session: session.add(task) session.commit() @@ -111,26 +130,11 @@ def update_task(self, task_id: int, data: dict) -> Optional[WatchTask]: task = session.get(WatchTask, task_id) if not task: return None - values = normalize_task_data({ - "name": task.name, - "keyword": task.keyword, - "max_price": task.max_price, - "min_price": task.min_price, - "exclude_words": task.exclude_words, - "condition_requirement": task.condition_requirement, - "min_condition_score": task.min_condition_score, - "platform": task.platform, - "interval_minutes": task.interval_minutes, - "fetch_detail": task.fetch_detail, - "enabled": task.enabled, - **data, - }) - for key in ( - "name", "keyword", "max_price", "min_price", "exclude_words", - "condition_requirement", "min_condition_score", "platform", - "interval_minutes", "fetch_detail", "enabled", - ): - setattr(task, key, values[key]) + values = normalize_task_data( + {**{field: getattr(task, field) for field in TASK_FIELDS}, **data} + ) + for field in TASK_FIELDS: + setattr(task, field, values[field]) session.commit() session.refresh(task) return task diff --git a/goodprice/web/routes.py b/goodprice/web/routes.py index 995fa5f..942e9d2 100644 --- a/goodprice/web/routes.py +++ b/goodprice/web/routes.py @@ -1,3 +1,4 @@ +import logging import threading from pathlib import Path from typing import Optional @@ -9,9 +10,44 @@ from pydantic import BaseModel, Field, field_validator, model_validator from sqlalchemy import func, text +from goodprice.constants import SCORE_MAX + +logger = logging.getLogger(__name__) + router = APIRouter() templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates")) +PAGE_SIZE = 100 + + +def _optional_int(value: str) -> Optional[int]: + """把查询参数安全转成 int;非数字返回 None,避免非法输入导致 500。""" + return int(value) if str(value).isdigit() else None + + +def _apply_listing_filters(query, *, task_id, show, sort): + """列表页 / 加载更多 / API 共用的过滤与排序,避免三处 if/elif 语义分叉。 + + show 的取值由模板下拉决定;未知值(含 "all")不追加过滤条件。 + """ + from goodprice.models import Listing + + if task_id: + query = query.filter(Listing.task_id == task_id) + if show == "active": + query = query.filter(Listing.status == "active", Listing.blocked.is_(False)) + elif show == "gone": + query = query.filter(Listing.status == "gone") + elif show == "blocked": + query = query.filter(Listing.blocked.is_(True)) + if sort == "price_asc": + return query.order_by(Listing.price.asc()) + if sort == "price_desc": + return query.order_by(Listing.price.desc()) + if sort == "newest": + return query.order_by(Listing.first_seen_at.desc()) + return query.order_by(Listing.satisfaction.desc(), Listing.first_seen_at.desc()) + @router.get("/healthz", include_in_schema=False) def healthz(request: Request): @@ -28,7 +64,7 @@ class TaskCreate(BaseModel): min_price: float = Field(default=0, ge=0, allow_inf_nan=False) exclude_words: str = "" condition_requirement: str = "" - min_condition_score: int = Field(default=0, ge=0, le=10) + min_condition_score: int = Field(default=0, ge=0, le=SCORE_MAX) platform: str = "xianyu" interval_minutes: int = Field(default=20, ge=1) fetch_detail: bool = True @@ -53,12 +89,13 @@ def _services(request: Request): return request.app.state.task_service, request.app.state.settings_service -def _login_context(login_session) -> dict: +def _login_context(login_session, novnc_port: int = 16080) -> dict: if login_session is None: return { "login_remote": False, "login_browser_accessible": False, "login_unavailable_reason": "登录功能不可用", + "novnc_port": novnc_port, } reason = getattr(login_session, "unavailable_reason", lambda: "") return { @@ -69,25 +106,14 @@ def _login_context(login_session) -> dict: getattr(login_session, "browser_accessible", True) ), "login_unavailable_reason": reason(), + "novnc_port": novnc_port, } def _task_dict(task) -> dict: - return { - "id": task.id, - "name": task.name, - "keyword": task.keyword, - "max_price": task.max_price, - "min_price": task.min_price, - "exclude_words": task.exclude_words, - "condition_requirement": task.condition_requirement, - "min_condition_score": task.min_condition_score, - "platform": task.platform, - "interval_minutes": task.interval_minutes, - "enabled": task.enabled, - "last_run_at": task.last_run_at.isoformat() if task.last_run_at else None, - "last_error": task.last_error, - } + from goodprice.services.task_service import task_to_dict + + return task_to_dict(task) @router.get("/", response_class=HTMLResponse) @@ -121,7 +147,9 @@ def tasks_page(request: Request): running_ids = request.app.state.guard.running_ids() queued_ids = _queued_ids(request) just_ran = request.query_params.get("run") - running, status, items = _progress_context(request, just_ran) + running, status, items = _progress_context( + request, just_ran, running_ids=running_ids, queued_ids=queued_ids + ) return templates.TemplateResponse( request, "tasks.html", @@ -145,10 +173,12 @@ def _queued_ids(request) -> set[int]: return set(queue.queued_ids()) -def _progress_context(request: Request, just_ran=None): +def _progress_context(request: Request, just_ran=None, *, running_ids=None, queued_ids=None): guard = request.app.state.guard - running_ids = guard.running_ids() - queued_ids = _queued_ids(request) + if running_ids is None: + running_ids = guard.running_ids() + if queued_ids is None: + queued_ids = _queued_ids(request) active = running_ids | queued_ids status = None if active: @@ -402,27 +432,12 @@ def listings_page( query = session.query(Listing) tasks = session.query(WatchTask).order_by(WatchTask.id).all() - task_id_int = int(task_id) if task_id else None - if task_id_int: - query = query.filter(Listing.task_id == task_id_int) - if show == "active": - query = query.filter(Listing.status == "active", Listing.blocked.is_(False)) - elif show == "gone": - query = query.filter(Listing.status == "gone") - elif show == "blocked": - query = query.filter(Listing.blocked.is_(True)) - if sort == "price_asc": - query = query.order_by(Listing.price.asc()) - elif sort == "price_desc": - query = query.order_by(Listing.price.desc()) - elif sort == "newest": - query = query.order_by(Listing.first_seen_at.desc()) - else: - query = query.order_by(Listing.satisfaction.desc(), Listing.first_seen_at.desc()) + task_id_int = _optional_int(task_id) + query = _apply_listing_filters(query, task_id=task_id_int, show=show, sort=sort) query = query.offset(offset) - listings = query.limit(100).all() + listings = query.limit(PAGE_SIZE).all() notify_counts = _notify_counts(session, listings) - more = len(listings) >= 100 + more = len(listings) >= PAGE_SIZE partial_url = f"/listings?partial=1&task_id={task_id or ''}&sort={sort}&show={show}" if partial: return templates.TemplateResponse( @@ -482,24 +497,9 @@ def listings_more( from goodprice.models import Listing query = session.query(Listing) - task_id_int = int(task_id) if task_id else None - if task_id_int: - query = query.filter(Listing.task_id == task_id_int) - if show == "active": - query = query.filter(Listing.status == "active", Listing.blocked.is_(False)) - elif show == "gone": - query = query.filter(Listing.status == "gone") - elif show == "blocked": - query = query.filter(Listing.blocked.is_(True)) - if sort == "price_asc": - query = query.order_by(Listing.price.asc()) - elif sort == "price_desc": - query = query.order_by(Listing.price.desc()) - elif sort == "newest": - query = query.order_by(Listing.first_seen_at.desc()) - else: - query = query.order_by(Listing.satisfaction.desc(), Listing.first_seen_at.desc()) - listings = query.offset(offset).limit(100).all() + task_id_int = _optional_int(task_id) + query = _apply_listing_filters(query, task_id=task_id_int, show=show, sort=sort) + listings = query.offset(offset).limit(PAGE_SIZE).all() notify_counts = _notify_counts(session, listings) return templates.TemplateResponse( request, "listings_cards.html", {"listings": listings, "notify_counts": notify_counts} @@ -528,7 +528,12 @@ def listing_detail_page(request: Request, listing_id: int): ) notify_count = sum(1 for n in notifications if n.status == "sent") first_price = snapshots[0].price if snapshots else listing.price - drop_pct = (first_price - listing.price) / first_price if first_price else 0.0 + from goodprice.services.satisfaction import ( + DROP_PCT_DISPLAY_THRESHOLD, + drop_pct_from_snapshots, + ) + + drop_pct = drop_pct_from_snapshots(listing) return templates.TemplateResponse( request, "listings_detail.html", @@ -539,6 +544,7 @@ def listing_detail_page(request: Request, listing_id: int): "notify_count": notify_count, "first_price": first_price, "drop_pct": drop_pct, + "drop_pct_threshold": DROP_PCT_DISPLAY_THRESHOLD, "active": "listings", }, ) @@ -651,7 +657,7 @@ def settings_page(request: Request): "settings": settings, "login_status": login_status, "login_message": login_message, - **_login_context(login_session), + **_login_context(login_session, settings.novnc_port), "active": "settings", }, ) @@ -686,86 +692,32 @@ def settings_login_status(request: Request): { "login_status": login_status, "login_message": login_message, - **_login_context(login_session), + **_login_context( + login_session, request.app.state.settings_service.get().novnc_port + ), }, ) @router.post("/settings") -def save_settings( - request: Request, - xianyu_cookie: str = Form(""), - llm_base_url: str = Form(""), - llm_api_key: str = Form(""), - llm_model: str = Form(""), - llm_api_format: str = Form("chat_completions"), - serverchan_sendkey: str = Form(""), - proxy: str = Form(""), - default_crawl_interval_minutes: int = Form(20), - default_crawl_jitter_minutes: int = Form(10), - vision_base_url: str = Form(""), - vision_api_key: str = Form(""), - vision_model: str = Form(""), - vision_api_format: str = Form("chat_completions"), - wecom_webhook: str = Form(""), - feishu_webhook: str = Form(""), - feishu_secret: str = Form(""), - serverchan_enabled: Optional[int] = Form(None), - wecom_robot_enabled: Optional[int] = Form(None), - feishu_enabled: Optional[int] = Form(None), - gotify_url: str = Form(""), - gotify_token: str = Form(""), - gotify_priority: int = Form(5), - gotify_enabled: Optional[int] = Form(None), - vision_enabled: Optional[int] = Form(None), - jev_enabled: Optional[int] = Form(None), - jev_auto_threshold: float = Form(0.85), - jev_backend: str = Form("adapter"), - jev_api_key: str = Form(""), -): +async def save_settings(request: Request): + from goodprice.config import SECRET_FIELDS, Settings + _, settings_service = _services(request) - values = { - "xianyu_cookie": xianyu_cookie, - "llm_base_url": llm_base_url, - "llm_api_key": llm_api_key, - "llm_model": llm_model, - "llm_api_format": llm_api_format, - "serverchan_sendkey": serverchan_sendkey, - "proxy": proxy, - "default_crawl_interval_minutes": str(default_crawl_interval_minutes), - "default_crawl_jitter_minutes": str(default_crawl_jitter_minutes), - "vision_base_url": vision_base_url, - "vision_api_key": vision_api_key, - "vision_model": vision_model, - "vision_api_format": vision_api_format, - "wecom_webhook": wecom_webhook, - "feishu_webhook": feishu_webhook, - "feishu_secret": feishu_secret, - "serverchan_enabled": "1" if serverchan_enabled else "0", - "wecom_robot_enabled": "1" if wecom_robot_enabled else "0", - "feishu_enabled": "1" if feishu_enabled else "0", - "gotify_url": gotify_url, - "gotify_token": gotify_token, - "gotify_priority": str(gotify_priority), - "gotify_enabled": "1" if gotify_enabled else "0", - "vision_enabled": "1" if vision_enabled else "0", - "jev_enabled": "1" if jev_enabled else "0", - "jev_auto_threshold": str(jev_auto_threshold), - "jev_backend": jev_backend if jev_backend in ("adapter", "typesafe") else "adapter", - "jev_api_key": jev_api_key, - } - for key in ( - "llm_api_key", - "serverchan_sendkey", - "vision_api_key", - "wecom_webhook", - "feishu_webhook", - "feishu_secret", - "gotify_token", - "jev_api_key", - ): - if values.get(key) == "": - values.pop(key) # 留空 = 保持原值 + form = await request.form() + values: dict[str, str] = {} + for field in Settings.model_fields: + if field.endswith("_enabled"): + # 复选框未勾选时不会出现在表单里,统一记为“关闭”。 + raw = form.get(field) + values[field] = "1" if str(raw).lower() in ("1", "true", "on", "yes") else "0" + elif field in form: + raw = form.get(field) + if isinstance(raw, str): + values[field] = raw + for field in SECRET_FIELDS: + if values.get(field) == "": + values.pop(field) # 留空 = 保持原值 runtime = settings_service.set_many(values) from goodprice.services.satisfaction import backfill_satisfaction @@ -778,21 +730,10 @@ def test_notification(request: Request, channel: str): from fastapi import HTTPException from goodprice.notify.base import NotificationMessage - from goodprice.notify.feishu import FeishuNotifier - from goodprice.notify.gotify import GotifyNotifier - from goodprice.notify.serverchan import ServerChanNotifier - from goodprice.notify.wecom_robot import WeComRobotNotifier + from goodprice.notify.registry import build_channel settings = request.app.state.settings_service.get() - notifiers = { - "serverchan": ServerChanNotifier(settings.serverchan_sendkey), - "wecom_robot": WeComRobotNotifier(settings.wecom_webhook), - "feishu": FeishuNotifier(settings.feishu_webhook, settings.feishu_secret), - "gotify": GotifyNotifier( - settings.gotify_url, settings.gotify_token, settings.gotify_priority - ), - } - notifier = notifiers.get(channel) + notifier = build_channel(channel, settings) if notifier is None: raise HTTPException(status_code=404, detail="未知通知渠道") if not notifier.enabled: @@ -807,6 +748,7 @@ def test_notification(request: Request, channel: str): ) ) except Exception: + logger.exception("测试通知[%s]发送失败", channel) return RedirectResponse( "/settings?" + urlencode({"toast": "发送失败,请查看容器日志"}), status_code=303 ) @@ -858,7 +800,8 @@ async def test_llm_endpoint(request: Request): try: body = await request.json() - except Exception: + except Exception as exc: + logger.debug("测试端点请求体解析失败,按空处理: %s", exc) body = {} settings = request.app.state.settings_service.get() @@ -884,10 +827,7 @@ async def test_llm_endpoint(request: Request): retry_delay=0, ) payload = {"messages": [{"role": "user", "content": "hello"}], "model": model} - if api_format == "responses": - client._complete_responses(payload, parser=lambda s: s) - else: - client._complete(payload, parser=lambda s: s) + client.complete(payload, parser=lambda s: s) ms = int((time.time() - t0) * 1000) return JSONResponse({"ok": True, "msg": f"连接成功!耗时 {ms}ms"}) except Exception as exc: @@ -904,7 +844,8 @@ async def test_vision_endpoint(request: Request): try: body = await request.json() - except Exception: + except Exception as exc: + logger.debug("测试端点请求体解析失败,按空处理: %s", exc) body = {} settings = request.app.state.settings_service.get() @@ -930,10 +871,7 @@ async def test_vision_endpoint(request: Request): retry_delay=0, ) payload = {"messages": [{"role": "user", "content": "hello"}], "model": model} - if api_format == "responses": - client._complete_responses(payload, parser=lambda s: s) - else: - client._complete(payload, parser=lambda s: s) + client.complete(payload, parser=lambda s: s) ms = int((time.time() - t0) * 1000) return JSONResponse({"ok": True, "msg": f"视觉模型连通成功!耗时 {ms}ms"}) except Exception as exc: @@ -948,7 +886,8 @@ async def test_jev_endpoint(request: Request): try: body = await request.json() - except Exception: + except Exception as exc: + logger.debug("测试端点请求体解析失败,按空处理: %s", exc) body = {} settings = request.app.state.settings_service.get() @@ -1037,24 +976,9 @@ def api_list_listings( from goodprice.models import Listing query = session.query(Listing) - task_id_int = int(task_id) if task_id else None - if task_id_int: - query = query.filter(Listing.task_id == task_id_int) - if show == "active": - query = query.filter(Listing.status == "active", Listing.blocked.is_(False)) - elif show == "gone": - query = query.filter(Listing.status == "gone") - elif show == "blocked": - query = query.filter(Listing.blocked.is_(True)) - if sort == "price_asc": - query = query.order_by(Listing.price.asc()) - elif sort == "price_desc": - query = query.order_by(Listing.price.desc()) - elif sort == "newest": - query = query.order_by(Listing.first_seen_at.desc()) - else: - query = query.order_by(Listing.satisfaction.desc(), Listing.first_seen_at.desc()) - rows = query.offset(offset).limit(100).all() + task_id_int = _optional_int(task_id) + query = _apply_listing_filters(query, task_id=task_id_int, show=show, sort=sort) + rows = query.offset(offset).limit(PAGE_SIZE).all() return [ { "id": row.id, diff --git a/goodprice/web/templates/listings_detail.html b/goodprice/web/templates/listings_detail.html index 20c699a..48766df 100644 --- a/goodprice/web/templates/listings_detail.html +++ b/goodprice/web/templates/listings_detail.html @@ -15,7 +15,7 @@