From 036770b14c9b6ae6df99528763203b08f770c033 Mon Sep 17 00:00:00 2001
From: wthsama <139784781+wthsama@users.noreply.github.com>
Date: Mon, 28 Sep 2026 17:44:48 +0800
Subject: [PATCH 1/5] =?UTF-8?q?refactor:=20=E6=94=B6=E6=95=9B=E5=A4=9A?=
=?UTF-8?q?=E5=A4=84=E5=B9=B3=E8=A1=8C=E7=BB=B4=E6=8A=A4=E4=B8=BA=E5=8D=95?=
=?UTF-8?q?=E4=B8=80=E4=BA=8B=E5=AE=9E=E6=9D=A5=E6=BA=90=EF=BC=88SSOT?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
把「同一逻辑事实在多处各写一份、靠人工保持一致」的冗余收敛到单一来源,并加一致性
测试守卫防漂移:
- 设置:全部字段只在 config.Settings 定义;RuntimeSettings 改为其别名、删除手写
类型集合;保存表单按 model_fields 动态收集;密钥清单抽 SECRET_FIELDS;
jev_backend 校验内聚为 field_validator;.env.example 补 SECOND_EYE_IMAGE 并注明
DATABASE_URL 在 Docker 下被 compose 覆盖;新增一致性守卫测试(字段覆盖/未知变量/
默认模型名)。
- 通知:新增 notify/registry.py 收敛渠道名/开关字段/构造,main 与 routes 均改用它;
新增 registry 守卫测试。
- Jev 提示词:抽取 REQUIREMENT_CRITERION/VALUE_CRITERION/REQUIREMENT_SYSTEM_PREFIX,
adapter 与 typesafe 两后端共用用户模板,锁措辞测试。
- 任务:TASK_FIELDS + task_to_dict 统一创建/更新/API 序列化,补回 fetch_detail。
- 列表:抽 _apply_listing_filters + PAGE_SIZE,列表页/加载更多/API 三处统一。
- 常量:新增 constants.py(评分刻度/风险等级)、parse_utils.clamp_score;UA 与
goofish 域名抽到 crawler/constants.py;__version__ 改从包元数据读取。
- DB:删除重复的 _LISTINGS_COLUMNS(改由 _COLUMN_DDL 派生),加「DDL 列 == ORM 列」
守卫;详情页 drop_pct 改用服务端函数、展示阈值抽常量。
验证:ruff 全绿;pytest 232 passed。
---
.env.example | 6 +-
goodprice/__init__.py | 7 +-
goodprice/analysis/jev_typesafe.py | 10 +-
goodprice/analysis/judge.py | 27 ++-
goodprice/analysis/llm.py | 5 +-
goodprice/analysis/parse_utils.py | 9 +
goodprice/analysis/prompts.py | 16 +-
goodprice/config.py | 35 ++-
goodprice/constants.py | 16 ++
goodprice/crawler/constants.py | 13 ++
goodprice/crawler/login.py | 6 +-
goodprice/crawler/xianyu.py | 7 +-
goodprice/db.py | 12 +-
goodprice/main.py | 30 +--
goodprice/notify/registry.py | 72 ++++++
goodprice/services/crawl_service.py | 3 +-
goodprice/services/satisfaction.py | 8 +-
goodprice/services/seller_service.py | 21 +-
goodprice/services/settings_service.py | 78 ++-----
goodprice/services/task_service.py | 72 +++---
goodprice/web/routes.py | 220 ++++++-------------
goodprice/web/templates/listings_detail.html | 2 +-
tests/test_api.py | 23 +-
tests/test_config.py | 3 +-
tests/test_db.py | 8 +
tests/test_notify_registry.py | 49 +++++
tests/test_prompts_consistency.py | 58 +++++
tests/test_settings_consistency.py | 54 +++++
tests/test_task_service.py | 10 +
29 files changed, 536 insertions(+), 344 deletions(-)
create mode 100644 goodprice/analysis/parse_utils.py
create mode 100644 goodprice/constants.py
create mode 100644 goodprice/crawler/constants.py
create mode 100644 goodprice/notify/registry.py
create mode 100644 tests/test_notify_registry.py
create mode 100644 tests/test_prompts_consistency.py
create mode 100644 tests/test_settings_consistency.py
diff --git a/.env.example b/.env.example
index db6afd9..9ec5bb5 100644
--- a/.env.example
+++ b/.env.example
@@ -57,7 +57,11 @@ 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
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..d8f1db0 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)] # Score 评分为索引空间 [0, 9]
-REQUIREMENT_NOUL_INSTRUCTION = "该二手商品是否满足买家的硬性需求?"
-VALUE_SCORE_INSTRUCTION = "该商品按当前价格是否划算,1 分最不划算,10 分最划算"
+REQUIREMENT_NOUL_INSTRUCTION = f"该二手{REQUIREMENT_CRITERION}?"
+VALUE_SCORE_INSTRUCTION = f"该商品{VALUE_CRITERION},1 分最不划算,10 分最划算"
@dataclass
@@ -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/judge.py b/goodprice/analysis/judge.py
index 4d610db..d39230d 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)
diff --git a/goodprice/analysis/llm.py b/goodprice/analysis/llm.py
index 07389ed..4c24d75 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()
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..2b6a309 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 = ""
@@ -48,6 +73,12 @@ class Settings(BaseSettings):
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..872848e
--- /dev/null
+++ b/goodprice/crawler/constants.py
@@ -0,0 +1,13 @@
+"""爬虫共用的网络常量。
+
+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}/"
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/xianyu.py b/goodprice/crawler/xianyu.py
index 3818ac5..d93051d 100644
--- a/goodprice/crawler/xianyu.py
+++ b/goodprice/crawler/xianyu.py
@@ -5,15 +5,12 @@
from goodprice.crawler import selectors as sel
from goodprice.crawler.base import CrawlerAuthError, ListingData, ListingDetail, SellerData
+from goodprice.crawler.constants import GOOFISH_ORIGIN, 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:
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..8c99148 100644
--- a/goodprice/main.py
+++ b/goodprice/main.py
@@ -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/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/services/crawl_service.py b/goodprice/services/crawl_service.py
index 0677dde..ef2722e 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,
)
@@ -592,7 +593,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..762737b 100644
--- a/goodprice/services/satisfaction.py
+++ b/goodprice/services/satisfaction.py
@@ -1,3 +1,9 @@
+from goodprice.constants import RISK_FACTOR, RISK_MEDIUM
+
+# 展示“较首见降价”的统一阈值:通知文案与详情页徽标共用,避免两处各写 0.05。
+DROP_PCT_DISPLAY_THRESHOLD = 0.05
+
+
def _drop_bonus(pct: float) -> float:
"""降价加成:较首见降幅 ≥8% 加 1 档,≥20% 加 2 档(折算进性价比维度)。"""
if pct >= 0.20:
@@ -32,7 +38,7 @@ def compute_satisfaction(
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..6285724 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__)
@@ -19,24 +20,24 @@ def compute_risk(
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..74f4f45 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,7 +31,7 @@ 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:
+ if not 0 <= min_condition_score <= SCORE_MAX:
raise ValueError("最低品相分必须在 0 到 10 之间")
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..b84e86b 100644
--- a/goodprice/web/routes.py
+++ b/goodprice/web/routes.py
@@ -9,9 +9,37 @@
from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy import func, text
+from goodprice.constants import SCORE_MAX
+
router = APIRouter()
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
+PAGE_SIZE = 100
+
+
+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 +56,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
@@ -73,21 +101,9 @@ def _login_context(login_session) -> dict:
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)
@@ -403,26 +419,11 @@ 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())
+ 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(
@@ -483,23 +484,8 @@ def listings_more(
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()
+ 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 +514,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 +530,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",
},
)
@@ -692,80 +684,24 @@ def settings_login_status(request: Request):
@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 +714,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:
@@ -1038,23 +963,8 @@ def api_list_listings(
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()
+ 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 @@
{{ listing.title }}
{% if notify_count > 1 %}已重推{% endif %}
{% if listing.blocked %}已拉黑{% endif %}
满足分 {{ listing.satisfaction|round(1) }}
- {% if drop_pct >= 0.05 %}较首见降价 {{ '%.0f'|format(drop_pct * 100) }}%{% endif %}
+ {% if drop_pct >= drop_pct_threshold %}较首见降价 {{ '%.0f'|format(drop_pct * 100) }}%{% endif %}
跳转商品页
diff --git a/tests/test_api.py b/tests/test_api.py
index ef9022d..5d04356 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -629,11 +629,15 @@ def test_task_detail_form_saves_round8_fields(base_settings, session_factory):
def _settings_form():
- return {
+ """设置页表单基线:字段须与 settings.html 一致,不得再出现已废弃字段。"""
+ from goodprice.config import Settings
+
+ form = {
"xianyu_cookie": "",
"llm_base_url": "",
"llm_api_key": "",
"llm_model": "qwen-vl-max",
+ "llm_api_format": "chat_completions",
"serverchan_sendkey": "",
"proxy": "",
"default_crawl_interval_minutes": "20",
@@ -641,14 +645,25 @@ def _settings_form():
"vision_base_url": "",
"vision_api_key": "",
"vision_model": "",
- "wecom_corpid": "",
- "wecom_agentid": "",
- "wecom_secret": "",
+ "vision_api_format": "chat_completions",
"wecom_webhook": "",
+ "feishu_webhook": "",
+ "feishu_secret": "",
+ "gotify_url": "",
+ "gotify_token": "",
+ "gotify_priority": "5",
+ "jev_api_key": "",
+ "jev_auto_threshold": "0.85",
+ "jev_backend": "adapter",
"serverchan_enabled": "1",
"wecom_robot_enabled": "1",
+ "feishu_enabled": "1",
+ "gotify_enabled": "1",
"vision_enabled": "1",
+ "jev_enabled": "0",
}
+ assert set(form) <= set(Settings.model_fields)
+ return form
def test_api_rejects_invalid_task_input(base_settings, session_factory):
diff --git a/tests/test_config.py b/tests/test_config.py
index 5ed3fc6..17e08b7 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -19,7 +19,8 @@ def test_settings_defaults():
def test_round2_settings_defaults():
settings = Settings(_env_file=None)
- assert settings.vision_model == "qwen-vl-max"
+ assert settings.llm_model == "glm-4.7-flash"
+ assert settings.vision_model == "glm-4.6v-flash"
assert settings.vision_api_format == "chat_completions"
assert settings.vision_base_url == ""
assert settings.serverchan_enabled is True
diff --git a/tests/test_db.py b/tests/test_db.py
index 58106f4..82906bb 100644
--- a/tests/test_db.py
+++ b/tests/test_db.py
@@ -22,6 +22,14 @@ def test_migrate_is_idempotent(session_factory):
migrate_schema(session_factory)
+def test_listings_ddl_covers_model_columns():
+ """重建表用的列定义必须与 ORM 模型完全一致,否则重建会丢列。"""
+ from goodprice.db import _COLUMN_DDL
+ from goodprice.models import Listing
+
+ assert set(_COLUMN_DDL) == {column.name for column in Listing.__table__.columns}
+
+
def test_migrate_adds_seller_columns(tmp_db):
engine = create_engine(tmp_db)
with engine.begin() as conn:
diff --git a/tests/test_notify_registry.py b/tests/test_notify_registry.py
new file mode 100644
index 0000000..8bd3af0
--- /dev/null
+++ b/tests/test_notify_registry.py
@@ -0,0 +1,49 @@
+"""通知渠道注册表的一致性守卫。
+
+渠道名/开关字段/构造逻辑集中在 goodprice.notify.registry;本测试保证注册表、
+Settings 字段与设置页模板三者不脱节。
+"""
+from goodprice.config import PROJECT_ROOT, Settings
+from goodprice.notify.registry import (
+ CHANNELS,
+ build_channel,
+ build_enabled_notifiers,
+ channel_names,
+)
+
+SETTINGS_HTML = PROJECT_ROOT / "goodprice" / "web" / "templates" / "settings.html"
+
+
+def test_channel_names_are_unique_and_expected():
+ names = [spec.name for spec in CHANNELS]
+ assert len(names) == len(set(names))
+ assert channel_names() == {"serverchan", "wecom_robot", "feishu", "gotify"}
+
+
+def test_channel_specs_point_to_settings_fields():
+ for spec in CHANNELS:
+ assert spec.enabled_field in Settings.model_fields, spec.name
+ notifier = build_channel(spec.name, Settings(_env_file=None))
+ assert notifier is not None
+ assert notifier.channel == spec.name
+
+
+def test_unknown_channel_returns_none():
+ assert build_channel("nope", Settings(_env_file=None)) is None
+
+
+def test_disabled_channels_are_skipped():
+ settings = Settings(
+ _env_file=None,
+ serverchan_enabled=False,
+ wecom_robot_enabled=False,
+ feishu_enabled=False,
+ gotify_enabled=False,
+ )
+ assert build_enabled_notifiers(settings) == []
+
+
+def test_template_exposes_every_channel():
+ html = SETTINGS_HTML.read_text(encoding="utf-8")
+ for spec in CHANNELS:
+ assert spec.name in html, f"settings.html 缺少渠道 {spec.name} 的配置区"
diff --git a/tests/test_prompts_consistency.py b/tests/test_prompts_consistency.py
new file mode 100644
index 0000000..57a0cc9
--- /dev/null
+++ b/tests/test_prompts_consistency.py
@@ -0,0 +1,58 @@
+"""提示词核心判据的单一来源守卫。
+
+需求匹配与性价比的判据措辞由 goodprice.analysis.prompts 统一定义,adapter 与
+typesafe 两个后端共用;本测试同时锁定措辞,防止重构时无意改动模型行为。
+"""
+from goodprice.analysis.jev_typesafe import (
+ REQUIREMENT_NOUL_INSTRUCTION,
+ VALUE_SCORE_INSTRUCTION,
+)
+from goodprice.analysis.judge import (
+ REQUIREMENT_TYPED_SYSTEM_PROMPT,
+ REQUIREMENT_TYPED_USER_TEMPLATE,
+ VALUE_TYPED_SYSTEM_PROMPT,
+)
+from goodprice.analysis.prompts import (
+ BATCH_VALUE_SYSTEM_PROMPT,
+ REQUIREMENT_CRITERION,
+ REQUIREMENT_SYSTEM_PREFIX,
+ REQUIREMENT_SYSTEM_PROMPT,
+ REQUIREMENT_USER_TEMPLATE,
+ VALUE_CRITERION,
+)
+
+
+def test_requirement_criterion_shared_across_backends():
+ for text in (
+ REQUIREMENT_SYSTEM_PROMPT,
+ REQUIREMENT_TYPED_SYSTEM_PROMPT,
+ REQUIREMENT_NOUL_INSTRUCTION,
+ ):
+ assert REQUIREMENT_CRITERION in text
+
+
+def test_value_criterion_shared_across_backends():
+ for text in (
+ BATCH_VALUE_SYSTEM_PROMPT,
+ VALUE_TYPED_SYSTEM_PROMPT,
+ VALUE_SCORE_INSTRUCTION,
+ ):
+ assert VALUE_CRITERION in text
+
+
+def test_prompt_wording_is_unchanged():
+ assert "请判断商品是否满足买家的硬性需求。" in REQUIREMENT_SYSTEM_PROMPT
+ assert "请判断商品是否满足买家的硬性需求。" in REQUIREMENT_TYPED_SYSTEM_PROMPT
+ assert REQUIREMENT_NOUL_INSTRUCTION == "该二手商品是否满足买家的硬性需求?"
+ assert "判断每个商品按当前价格是否划算。" in BATCH_VALUE_SYSTEM_PROMPT
+ assert "请仅依据该商品本身独立评估按当前价格是否划算。" in VALUE_TYPED_SYSTEM_PROMPT
+ assert VALUE_SCORE_INSTRUCTION == "该商品按当前价格是否划算,1 分最不划算,10 分最划算"
+
+
+def test_requirement_user_template_is_shared():
+ assert REQUIREMENT_TYPED_USER_TEMPLATE == REQUIREMENT_USER_TEMPLATE
+
+
+def test_requirement_system_prefix_is_shared():
+ assert REQUIREMENT_SYSTEM_PROMPT.startswith(REQUIREMENT_SYSTEM_PREFIX)
+ assert REQUIREMENT_TYPED_SYSTEM_PROMPT.startswith(REQUIREMENT_SYSTEM_PREFIX)
diff --git a/tests/test_settings_consistency.py b/tests/test_settings_consistency.py
new file mode 100644
index 0000000..e76da59
--- /dev/null
+++ b/tests/test_settings_consistency.py
@@ -0,0 +1,54 @@
+"""守住「设置字段只在一处定义」的边界。
+
+设置字段的唯一事实来源是 goodprice.config.Settings;.env.example 是它的部署文档。
+本测试保证新增字段时不会漏写文档、也不会凭空多出无人读取的变量,从根上防止
+「代码 / 模板 / 表单」多处平行维护导致的漂移。
+"""
+from goodprice.config import PROJECT_ROOT, Settings
+
+ENV_EXAMPLE = PROJECT_ROOT / ".env.example"
+
+# 有意不写入 .env.example 的字段:内部字段或由容器注入。
+INTERNAL_FIELDS = {"app_name", "runtime_mode"}
+
+# .env.example 中属于 compose/部署层、并非 Settings 字段的变量。
+COMPOSE_ONLY_KEYS = {"BIND_ADDRESS", "SECOND_EYE_IMAGE"}
+
+
+def _env_example_keys() -> set[str]:
+ keys: set[str] = set()
+ for line in ENV_EXAMPLE.read_text(encoding="utf-8").splitlines():
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ keys.add(line.split("=", 1)[0].strip())
+ return keys
+
+
+def test_env_example_documents_every_settings_field():
+ keys = _env_example_keys()
+ missing = sorted(
+ name.upper()
+ for name in Settings.model_fields
+ if name not in INTERNAL_FIELDS and name.upper() not in keys
+ )
+ assert not missing, f".env.example 缺少以下设置项: {missing}"
+
+
+def test_env_example_has_no_unknown_variables():
+ known = {name.upper() for name in Settings.model_fields} | COMPOSE_ONLY_KEYS
+ unknown = sorted(_env_example_keys() - known)
+ assert not unknown, f".env.example 含未知变量(可能与代码脱节): {unknown}"
+
+
+def test_env_example_model_defaults_match_settings():
+ """默认模型名只在 Settings 定义;.env.example 的取值必须与之一致。"""
+ values = {}
+ for line in ENV_EXAMPLE.read_text(encoding="utf-8").splitlines():
+ line = line.strip()
+ if line and not line.startswith("#") and "=" in line:
+ key, _, value = line.partition("=")
+ values[key.strip()] = value.strip()
+ defaults = Settings(_env_file=None)
+ assert values["LLM_MODEL"] == defaults.llm_model
+ assert values["VISION_MODEL"] == defaults.vision_model
diff --git a/tests/test_task_service.py b/tests/test_task_service.py
index b41adfd..ac19ab6 100644
--- a/tests/test_task_service.py
+++ b/tests/test_task_service.py
@@ -87,3 +87,13 @@ def test_normalizes_task_text_and_numbers(session_factory):
assert task.condition_requirement == "screen"
assert task.min_price == 10.0
assert task.interval_minutes == 5
+
+
+def test_task_to_dict_covers_all_editable_fields(session_factory):
+ from goodprice.services.task_service import TASK_FIELDS, TaskService, task_to_dict
+
+ task = TaskService(session_factory).create_task({"keyword": "k", "fetch_detail": False})
+ data = task_to_dict(task)
+ assert set(TASK_FIELDS) <= set(data)
+ assert data["fetch_detail"] is False
+ assert "last_run_count" in data
From 1eabef9fcefc41dd2bbf89103f0febbc688bb33b Mon Sep 17 00:00:00 2001
From: wthsama <139784781+wthsama@users.noreply.github.com>
Date: Mon, 28 Sep 2026 17:56:11 +0800
Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=AE=A1=E8=AE=A1?=
=?UTF-8?q?=E5=8F=91=E7=8E=B0=E7=9A=84=E7=88=AC=E8=99=AB/=E6=95=B0?=
=?UTF-8?q?=E6=8D=AE=E7=BC=BA=E9=99=B7=E4=B8=8E=E8=B5=84=E6=BA=90=E6=B3=84?=
=?UTF-8?q?=E6=BC=8F=EF=BC=88P3/P4=20=E4=B9=8B=E4=B8=80=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- crawler: 搜索结果卡片的 seller-text-- 实为地区文本,原先 SELLER 与 LOCATION
同选择器导致 seller 与 location 恒等、地区错位;现 location 采地区、seller 置空
(卖家昵称仅从详情页取)。同步修正测试,BASE_URL 改引用 crawler.constants。
- notify: serverchan / wecom_robot 的 httpx.Client 改用 with 关闭,修复连接泄漏。
- crawler.parser: _parse_seller_block 去掉从未使用的第 4 个返回值并补类型标注。
- models: ORM 的 JSON 列补 Mapped 元素类型(list[str]/dict[str, Any])。
- json_extract: 修正“可选依赖 json-repair”的过时措辞(它已是核心依赖)。
- settings.html: “本地/Codex”预设不再硬编码内网 IP 与私有模型名,改通用占位。
- 工程:.dockerignore 补齐 .venv/缓存/构建产物;compose 固定 gotify 3.1.1。
- 测试:新增 _safe_test_url(SSRF)单测;去重复导入;测试名去轮次绑定。
验证:ruff 全绿;pytest 240 passed。
---
.dockerignore | 9 +++++++
compose.yml | 2 +-
goodprice/analysis/json_extract.py | 6 ++---
goodprice/crawler/parser.py | 16 +++++++------
goodprice/crawler/selectors.py | 2 +-
goodprice/models.py | 12 +++++-----
goodprice/notify/serverchan.py | 14 +++++------
goodprice/notify/wecom_robot.py | 12 +++++-----
goodprice/web/templates/settings.html | 9 +++----
tests/test_crawler_parser.py | 10 ++++----
tests/test_judge.py | 2 --
tests/test_models.py | 6 ++---
tests/test_web_safety.py | 34 +++++++++++++++++++++++++++
13 files changed, 89 insertions(+), 45 deletions(-)
create mode 100644 tests/test_web_safety.py
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/compose.yml b/compose.yml
index c6090a7..d6c580d 100644
--- a/compose.yml
+++ b/compose.yml
@@ -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/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/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/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/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/web/templates/settings.html b/goodprice/web/templates/settings.html
index 3a29094..5642c0a 100644
--- a/goodprice/web/templates/settings.html
+++ b/goodprice/web/templates/settings.html
@@ -404,11 +404,12 @@
4. 消息推送渠道
document.getElementById('vision_model').value = 'glm-4.6v-flash';
document.getElementById('vision_api_format').value = 'chat_completions';
} else if (type === 'local') {
- document.getElementById('llm_base_url').value = 'http://192.168.5.21:15722/v1';
- document.getElementById('llm_model').value = 'gpt-5.6-luna';
+ // 通用占位:请替换为你的 OpenAI 兼容中转地址,不要写入具体内网 IP。
+ document.getElementById('llm_base_url').value = 'http://<你的中转地址>/v1';
+ document.getElementById('llm_model').value = '';
document.getElementById('llm_api_format').value = 'responses';
- document.getElementById('vision_base_url').value = 'http://192.168.5.21:15722/v1';
- document.getElementById('vision_model').value = 'gpt-5.6-luna';
+ document.getElementById('vision_base_url').value = 'http://<你的中转地址>/v1';
+ document.getElementById('vision_model').value = '';
document.getElementById('vision_api_format').value = 'responses';
}
}
diff --git a/tests/test_crawler_parser.py b/tests/test_crawler_parser.py
index 2084e7f..8baea0e 100644
--- a/tests/test_crawler_parser.py
+++ b/tests/test_crawler_parser.py
@@ -4,6 +4,8 @@
from goodprice.crawler.parser import (
extract_id,
+ extract_user_id,
+ is_product_image,
parse_detail_html,
parse_price,
parse_search_html,
@@ -38,12 +40,14 @@ def test_parse_search_html():
assert first.price == 2999.0
assert first.url == "https://www.goofish.com/item?id=1001&categoryId=1"
assert first.image_urls == ["https://img.alicdn.com/bao/uploaded/1001.jpg"]
- assert first.seller == "杭州"
+ # 搜索卡片里 seller-text-- 实际是地区文本,不是卖家昵称
+ assert first.seller is None
assert first.location == "杭州"
second = items[1]
assert second.external_id == "1002"
assert second.price == 450.0
assert second.seller is None
+ assert second.location is None
def test_parse_search_html_drops_placeholder_images():
@@ -100,15 +104,11 @@ def test_parse_detail_html_price_range_as_variants():
def test_extract_user_id():
- from goodprice.crawler.parser import extract_user_id
-
assert extract_user_id("https://www.goofish.com/personal?userId=2672367114") == "2672367114"
assert extract_user_id("https://x/other") is None
def test_is_product_image_filters_placeholder():
- from goodprice.crawler.parser import is_product_image
-
assert is_product_image("https://img.alicdn.com/bao/uploaded/i2/x.jpg") is True
assert is_product_image("https://img.alicdn.com/imgextra/i4/xxx-2-tps-2-2.png") is False
assert is_product_image("https://img.alicdn.com/imgextra/i1/xxx-tps-480-144.png") is False
diff --git a/tests/test_judge.py b/tests/test_judge.py
index 9b2da9a..b6ce08d 100644
--- a/tests/test_judge.py
+++ b/tests/test_judge.py
@@ -133,8 +133,6 @@ def test_build_judger_typesafe_backend():
backend="typesafe",
api_key="ts-key",
)
- from goodprice.analysis.jev_typesafe import TypeSafeJudger
-
assert isinstance(judger, TypeSafeJudger)
assert judger.enabled is True
assert build_judger(
diff --git a/tests/test_models.py b/tests/test_models.py
index 3b0e6a8..29d5fcb 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -51,7 +51,7 @@ def test_listing_relations(session_factory):
assert len(listing.notifications) == 1
-def test_round2_model_columns(session_factory):
+def test_task_and_listing_optional_columns(session_factory):
with session_factory() as session:
task = WatchTask(keyword="k")
session.add(task)
@@ -99,7 +99,7 @@ def test_blocked_flags(session_factory):
assert s.blocked is True
-def test_round7_listing_columns(session_factory):
+def test_listing_status_and_value_defaults(session_factory):
with session_factory() as session:
listing = Listing(platform="xianyu", external_id="1", title="t", price=1, url="u")
session.add(listing)
@@ -113,7 +113,7 @@ def test_round7_listing_columns(session_factory):
assert listing.last_notified_satisfaction is None
-def test_round8_task_columns(session_factory):
+def test_task_price_and_exclude_defaults(session_factory):
with session_factory() as session:
task = WatchTask(keyword="k")
session.add(task)
diff --git a/tests/test_web_safety.py b/tests/test_web_safety.py
new file mode 100644
index 0000000..f32a18e
--- /dev/null
+++ b/tests/test_web_safety.py
@@ -0,0 +1,34 @@
+"""「测试连接」地址校验(SSRF 防护)的单元测试。"""
+import pytest
+
+from goodprice.web.routes import _safe_test_url
+
+
+def test_empty_returns_empty():
+ assert _safe_test_url("") == ""
+ assert _safe_test_url(" ") == ""
+
+
+def test_allows_public_https():
+ url = "https://open.bigmodel.cn/api/paas/v4"
+ assert _safe_test_url(url) == url
+
+
+def test_allows_loopback_and_private_lan():
+ assert _safe_test_url("http://127.0.0.1:8000/v1") == "http://127.0.0.1:8000/v1"
+ assert _safe_test_url("http://192.168.1.5:1234/v1") == "http://192.168.1.5:1234/v1"
+
+
+@pytest.mark.parametrize(
+ "bad",
+ [
+ "ftp://example.com/v1",
+ "javascript:alert(1)",
+ "http://metadata.google.internal/",
+ "http://169.254.169.254/latest/meta-data/",
+ "http://0.0.0.0:8000",
+ ],
+)
+def test_blocks_unsafe_targets(bad):
+ with pytest.raises(ValueError):
+ _safe_test_url(bad)
From 13ad2ca1795aed40aa3f5570e8be52fa62e22697 Mon Sep 17 00:00:00 2001
From: wthsama <139784781+wthsama@users.noreply.github.com>
Date: Mon, 28 Sep 2026 17:56:17 +0800
Subject: [PATCH 3/5] =?UTF-8?q?refactor:=20=E5=8F=AF=E7=BB=B4=E6=8A=A4?=
=?UTF-8?q?=E6=80=A7=E6=94=B6=E6=95=9B=E4=B8=8E=20nit=20=E6=B8=85=E7=90=86?=
=?UTF-8?q?=EF=BC=88P3/P4=20=E4=B9=8B=E4=BA=8C=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- LLMClient 暴露公开 payload()/complete(),judge 与测试端点不再访问私有方法。
- routes: 抽 _optional_int 容错 task_id 查询参数;_progress_context 复用已算出的
running/queued;测试通知失败改 logger.exception;测试端点请求体解析失败记 debug。
- crawl_service: 抽 _evaluate_and_queue 消除新品/重评的重复评估分支;
build_judger 的不可用原因按 backend 判断。
- xianyu: 抽 _open_page 统一浏览器/上下文/Cookie 装配;超时与等待抽为常量。
- satisfaction/task_service: 评分刻度改用 SCORE_MAX,不再硬编码 10。
- jev_typesafe: 修正返回标注(RequirementVerdict)与索引空间注释。
- main: 用 PYTEST_CURRENT_TEST 替代 sys.modules 探测测试环境。
- 删除无引用的死模板 tasks_edit.html;测试命名与 pytest.raises 整理。
验证:ruff 全绿;pytest 240 passed。
---
goodprice/analysis/jev_typesafe.py | 4 +-
goodprice/analysis/judge.py | 4 +-
goodprice/analysis/llm.py | 10 +-
goodprice/crawler/constants.py | 7 +
goodprice/crawler/xianyu.py | 162 +++++++++++-------------
goodprice/main.py | 4 +-
goodprice/services/crawl_service.py | 58 +++++----
goodprice/services/satisfaction.py | 8 +-
goodprice/services/seller_service.py | 2 +-
goodprice/services/task_service.py | 2 +-
goodprice/web/routes.py | 46 ++++---
goodprice/web/templates/tasks_edit.html | 47 -------
tests/test_api.py | 7 +-
tests/test_config.py | 4 +-
tests/test_db.py | 4 +-
tests/test_settings_service.py | 4 +-
tests/test_task_service.py | 8 +-
17 files changed, 176 insertions(+), 205 deletions(-)
delete mode 100644 goodprice/web/templates/tasks_edit.html
diff --git a/goodprice/analysis/jev_typesafe.py b/goodprice/analysis/jev_typesafe.py
index d8f1db0..5299330 100644
--- a/goodprice/analysis/jev_typesafe.py
+++ b/goodprice/analysis/jev_typesafe.py
@@ -11,7 +11,7 @@
logger = logging.getLogger(__name__)
-VALUE_LEVELS = [f"{i} 分" for i in range(SCORE_MIN, SCORE_MAX + 1)] # 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 = f"该二手{REQUIREMENT_CRITERION}?"
VALUE_SCORE_INSTRUCTION = f"该商品{VALUE_CRITERION},1 分最不划算,10 分最划算"
@@ -30,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 = (
diff --git a/goodprice/analysis/judge.py b/goodprice/analysis/judge.py
index d39230d..a4612f9 100644
--- a/goodprice/analysis/judge.py
+++ b/goodprice/analysis/judge.py
@@ -154,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 4c24d75..b779102 100644
--- a/goodprice/analysis/llm.py
+++ b/goodprice/analysis/llm.py
@@ -157,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 "无"
@@ -188,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/crawler/constants.py b/goodprice/crawler/constants.py
index 872848e..b36c030 100644
--- a/goodprice/crawler/constants.py
+++ b/goodprice/crawler/constants.py
@@ -11,3 +11,10 @@
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/xianyu.py b/goodprice/crawler/xianyu.py
index d93051d..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,7 +6,15 @@
from goodprice.crawler import selectors as sel
from goodprice.crawler.base import CrawlerAuthError, ListingData, ListingDetail, SellerData
-from goodprice.crawler.constants import GOOFISH_ORIGIN, USER_AGENT
+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 = f"{GOOFISH_ORIGIN}/search?q={{keyword}}&spm=a21ybx.search.searchInput.0"
@@ -39,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,
@@ -57,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/main.py b/goodprice/main.py
index 8c99148..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,
diff --git a/goodprice/services/crawl_service.py b/goodprice/services/crawl_service.py
index ef2722e..a305eab 100644
--- a/goodprice/services/crawl_service.py
+++ b/goodprice/services/crawl_service.py
@@ -144,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
@@ -222,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)
@@ -413,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
diff --git a/goodprice/services/satisfaction.py b/goodprice/services/satisfaction.py
index 762737b..3244b29 100644
--- a/goodprice/services/satisfaction.py
+++ b/goodprice/services/satisfaction.py
@@ -1,4 +1,4 @@
-from goodprice.constants import RISK_FACTOR, RISK_MEDIUM
+from goodprice.constants import RISK_FACTOR, RISK_MEDIUM, SCORE_MAX
# 展示“较首见降价”的统一阈值:通知文案与详情页徽标共用,避免两处各写 0.05。
DROP_PCT_DISPLAY_THRESHOLD = 0.05
@@ -29,12 +29,12 @@ 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")
diff --git a/goodprice/services/seller_service.py b/goodprice/services/seller_service.py
index 6285724..4ea2216 100644
--- a/goodprice/services/seller_service.py
+++ b/goodprice/services/seller_service.py
@@ -13,7 +13,7 @@ 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 ""
diff --git a/goodprice/services/task_service.py b/goodprice/services/task_service.py
index 74f4f45..84c3369 100644
--- a/goodprice/services/task_service.py
+++ b/goodprice/services/task_service.py
@@ -32,7 +32,7 @@ def number(name: str) -> float:
except (TypeError, ValueError) as exc:
raise ValueError("品相分数和抓取间隔必须是整数") from exc
if not 0 <= min_condition_score <= SCORE_MAX:
- raise ValueError("最低品相分必须在 0 到 10 之间")
+ raise ValueError(f"最低品相分必须在 0 到 {SCORE_MAX} 之间")
if interval_minutes < 1:
raise ValueError("抓取间隔不能小于 1 分钟")
diff --git a/goodprice/web/routes.py b/goodprice/web/routes.py
index b84e86b..511dbe3 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
@@ -11,12 +12,19 @@
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 语义分叉。
@@ -137,7 +145,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",
@@ -161,10 +171,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:
@@ -418,7 +430,7 @@ 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
+ 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(PAGE_SIZE).all()
@@ -483,7 +495,7 @@ def listings_more(
from goodprice.models import Listing
query = session.query(Listing)
- task_id_int = int(task_id) if task_id else None
+ 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)
@@ -732,6 +744,7 @@ def test_notification(request: Request, channel: str):
)
)
except Exception:
+ logger.exception("测试通知[%s]发送失败", channel)
return RedirectResponse(
"/settings?" + urlencode({"toast": "发送失败,请查看容器日志"}), status_code=303
)
@@ -783,7 +796,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()
@@ -809,10 +823,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:
@@ -829,7 +840,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()
@@ -855,10 +867,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:
@@ -873,7 +882,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()
@@ -962,7 +972,7 @@ def api_list_listings(
from goodprice.models import Listing
query = session.query(Listing)
- task_id_int = int(task_id) if task_id else None
+ 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 [
diff --git a/goodprice/web/templates/tasks_edit.html b/goodprice/web/templates/tasks_edit.html
deleted file mode 100644
index a8ec8be..0000000
--- a/goodprice/web/templates/tasks_edit.html
+++ /dev/null
@@ -1,47 +0,0 @@
-{% extends "base.html" %}
-{% block title %}编辑任务 - 闲鱼盯价助手{% endblock %}
-{% block content %}
-
-
编辑监控任务 #{{ task.id }}
-
-
-{% endblock %}
diff --git a/tests/test_api.py b/tests/test_api.py
index 5d04356..e978f8d 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -565,7 +565,7 @@ def test_settings_save_recomputes_satisfaction(base_settings, session_factory):
assert session.query(Listing).one().satisfaction == 90.0
-def test_task_api_accepts_round8_fields(base_settings, session_factory):
+def test_task_api_accepts_price_and_exclude_fields(base_settings, session_factory):
client = _client(base_settings, session_factory)
task = client.post(
"/api/tasks",
@@ -610,7 +610,7 @@ def test_edit_page_redirects_to_detail(base_settings, session_factory):
assert f"/tasks/{task['id']}" in resp.headers["location"]
-def test_task_detail_form_saves_round8_fields(base_settings, session_factory):
+def test_task_detail_form_saves_price_and_exclude_fields(base_settings, session_factory):
client = _client(base_settings, session_factory)
task = client.post("/api/tasks", json={"keyword": "k"}).json()
resp = client.post(
@@ -632,11 +632,12 @@ def _settings_form():
"""设置页表单基线:字段须与 settings.html 一致,不得再出现已废弃字段。"""
from goodprice.config import Settings
+ defaults = Settings(_env_file=None)
form = {
"xianyu_cookie": "",
"llm_base_url": "",
"llm_api_key": "",
- "llm_model": "qwen-vl-max",
+ "llm_model": defaults.llm_model,
"llm_api_format": "chat_completions",
"serverchan_sendkey": "",
"proxy": "",
diff --git a/tests/test_config.py b/tests/test_config.py
index 17e08b7..cd5e54b 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -17,7 +17,7 @@ def test_settings_defaults():
assert settings.default_crawl_jitter_minutes == 10
-def test_round2_settings_defaults():
+def test_llm_and_vision_defaults():
settings = Settings(_env_file=None)
assert settings.llm_model == "glm-4.7-flash"
assert settings.vision_model == "glm-4.6v-flash"
@@ -28,7 +28,7 @@ def test_round2_settings_defaults():
assert settings.vision_enabled is True
-def test_round2_settings_env_overrides(monkeypatch):
+def test_vision_env_overrides(monkeypatch):
monkeypatch.setenv("VISION_ENABLED", "false")
monkeypatch.setenv("VISION_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
settings = Settings(_env_file=None)
diff --git a/tests/test_db.py b/tests/test_db.py
index 82906bb..85cb302 100644
--- a/tests/test_db.py
+++ b/tests/test_db.py
@@ -66,7 +66,7 @@ def test_migrate_adds_notification_columns(tmp_db):
assert {"title", "content"} <= cols
-def test_migrate_adds_round7_columns(tmp_db):
+def test_migrate_adds_listing_value_columns(tmp_db):
engine = create_engine(tmp_db)
with engine.begin() as conn:
conn.execute(text("CREATE TABLE listings (id INTEGER PRIMARY KEY, external_id TEXT)"))
@@ -85,7 +85,7 @@ def test_migrate_adds_round7_columns(tmp_db):
} <= cols
-def test_migrate_adds_round8_task_columns(tmp_db):
+def test_migrate_adds_task_price_columns(tmp_db):
engine = create_engine(tmp_db)
with engine.begin() as conn:
conn.execute(text("CREATE TABLE watch_tasks (id INTEGER PRIMARY KEY, keyword TEXT)"))
diff --git a/tests/test_settings_service.py b/tests/test_settings_service.py
index c64f725..9554a62 100644
--- a/tests/test_settings_service.py
+++ b/tests/test_settings_service.py
@@ -25,7 +25,7 @@ def test_empty_value_clears_override(session_factory, base_settings):
assert service.get().xianyu_cookie == ""
-def test_round2_settings_persist(session_factory, base_settings):
+def test_webhook_and_vision_persist(session_factory, base_settings):
service = SettingsService(session_factory, base=base_settings)
service.set_many({"wecom_webhook": "https://x/send?key=abc", "vision_model": "glm-4v-flash", "vision_api_format": "responses"})
settings = service.get()
@@ -35,7 +35,7 @@ def test_round2_settings_persist(session_factory, base_settings):
assert settings.serverchan_enabled is True
-def test_round5_toggle_persist(session_factory, base_settings):
+def test_toggle_flags_persist(session_factory, base_settings):
service = SettingsService(session_factory, base=base_settings)
service.set_many({"serverchan_enabled": "0", "vision_enabled": "0"})
settings = service.get()
diff --git a/tests/test_task_service.py b/tests/test_task_service.py
index ac19ab6..d7e4560 100644
--- a/tests/test_task_service.py
+++ b/tests/test_task_service.py
@@ -1,3 +1,5 @@
+import pytest
+
from goodprice.services.task_service import TaskService
@@ -60,12 +62,8 @@ def test_rejects_invalid_task_ranges(session_factory):
{"keyword": "x", "interval_minutes": 0},
{"keyword": "x", "max_price": float("inf")},
):
- try:
+ with pytest.raises(ValueError):
service.create_task(data)
- except ValueError:
- pass
- else:
- raise AssertionError(f"expected invalid data to fail: {data}")
def test_normalizes_task_text_and_numbers(session_factory):
From 7e33b82da8065ec781dff06537dc728fd2bfe687 Mon Sep 17 00:00:00 2001
From: wthsama <139784781+wthsama@users.noreply.github.com>
Date: Mon, 28 Sep 2026 18:16:41 +0800
Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20noVNC=20=E5=AF=B9=E5=A4=96?=
=?UTF-8?q?=E7=AB=AF=E5=8F=A3=E6=94=B6=E6=95=9B=E4=B8=BA=20NOVNC=5FPORT?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
端口原先在 compose 端口映射、登录页模板、README 与启动脚本各写一份。现由
Settings.novnc_port 定义、.env.example 记录,compose 映射与登录页链接(经
_login_context 传入 novnc_port)共用,启动脚本读 ${NOVNC_PORT:-16080}。
验证:ruff 全绿;pytest 240 passed。
---
.env.example | 2 ++
README.md | 2 +-
compose.yml | 2 +-
goodprice/config.py | 1 +
goodprice/web/routes.py | 10 +++++++---
goodprice/web/templates/login_status.html | 2 +-
scripts/start-docker.sh | 2 +-
7 files changed, 14 insertions(+), 7 deletions(-)
diff --git a/.env.example b/.env.example
index 9ec5bb5..c25efc0 100644
--- a/.env.example
+++ b/.env.example
@@ -68,3 +68,5 @@ DATABASE_URL=sqlite:///data/goodprice.db
# 写入独立密码;登录完成后恢复为 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 d6c580d..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:-}
diff --git a/goodprice/config.py b/goodprice/config.py
index 2b6a309..feb741a 100644
--- a/goodprice/config.py
+++ b/goodprice/config.py
@@ -68,6 +68,7 @@ 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"
diff --git a/goodprice/web/routes.py b/goodprice/web/routes.py
index 511dbe3..942e9d2 100644
--- a/goodprice/web/routes.py
+++ b/goodprice/web/routes.py
@@ -89,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 {
@@ -105,6 +106,7 @@ def _login_context(login_session) -> dict:
getattr(login_session, "browser_accessible", True)
),
"login_unavailable_reason": reason(),
+ "novnc_port": novnc_port,
}
@@ -655,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",
},
)
@@ -690,7 +692,9 @@ 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
+ ),
},
)
diff --git a/goodprice/web/templates/login_status.html b/goodprice/web/templates/login_status.html
index 78ddab1..e9e865b 100644
--- a/goodprice/web/templates/login_status.html
+++ b/goodprice/web/templates/login_status.html
@@ -3,7 +3,7 @@
{{ login_message }}
{% if login_remote and login_browser_accessible %}
- 请打开
{{ request.url.hostname }}:16080/vnc.html 查看浏览器窗口,完成登录后会自动保存 Cookie。
+ 请打开
{{ request.url.hostname }}:{{ novnc_port }}/vnc.html 查看浏览器窗口,完成登录后会自动保存 Cookie。
{% elif not login_remote %}
请在本机新打开的浏览器窗口中扫码或登录,完成后会自动保存 Cookie。
{% endif %}
diff --git a/scripts/start-docker.sh b/scripts/start-docker.sh
index ac4d261..00f8ebf 100755
--- a/scripts/start-docker.sh
+++ b/scripts/start-docker.sh
@@ -50,7 +50,7 @@ fi
echo "second-eye 已启动:http://127.0.0.1:18000"
if [ "$login" -eq 1 ]; then
- echo "登录模式已启用 noVNC:http://<主机IP>:16080/vnc.html"
+ echo "登录模式已启用 noVNC:http://<主机IP>:${NOVNC_PORT:-16080}/vnc.html"
echo "登录完成后请重新执行:$0(关闭 noVNC)"
else
echo "普通运行模式:noVNC 已关闭。需要登录时执行 $0 --login"
From e651adb250820be12b267c66a6ecb61a1de94226 Mon Sep 17 00:00:00 2001
From: Comui520
Date: Tue, 29 Sep 2026 10:50:46 +0800
Subject: [PATCH 5/5] fix: read noVNC port from dotenv in helper
---
scripts/start-docker.sh | 20 +++++++++++++++++++-
1 file changed, 19 insertions(+), 1 deletion(-)
diff --git a/scripts/start-docker.sh b/scripts/start-docker.sh
index 00f8ebf..9262468 100755
--- a/scripts/start-docker.sh
+++ b/scripts/start-docker.sh
@@ -48,9 +48,27 @@ else
docker compose up -d --build
fi
+# Compose reads project variables from the shell first, then .env. Mirror that lookup for
+# the printed login URL without sourcing .env as shell code.
+novnc_port="${NOVNC_PORT:-}"
+if [ -z "$novnc_port" ] && [ -f .env ]; then
+ novnc_port="$(awk '
+ /^[[:space:]]*#/ { next }
+ /^[[:space:]]*NOVNC_PORT[[:space:]]*=/ {
+ value = $0
+ sub(/^[^=]*=/, "", value)
+ sub(/[[:space:]]+#.*/, "", value)
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
+ gsub(/^["\047]|["\047]$/, "", value)
+ }
+ END { print value }
+ ' .env)"
+fi
+novnc_port="${novnc_port:-16080}"
+
echo "second-eye 已启动:http://127.0.0.1:18000"
if [ "$login" -eq 1 ]; then
- echo "登录模式已启用 noVNC:http://<主机IP>:${NOVNC_PORT:-16080}/vnc.html"
+ echo "登录模式已启用 noVNC:http://<主机IP>:$novnc_port/vnc.html"
echo "登录完成后请重新执行:$0(关闭 noVNC)"
else
echo "普通运行模式:noVNC 已关闭。需要登录时执行 $0 --login"