refactor(pipeline): OpenAI 网关与配料视觉抽离至 openai_gateway

新增 pipeline/openai_gateway:credentials、text_chat、ingredients_op、ingredients_defaults 等;AI_crawler 仅加载 .env 与重导出。CrawlerOpenAiCompatible 与京东详情/流水线改走 import pipeline.openai_gateway,不再依赖 sys.path 注入爬虫目录。llm/providers/shared 转重导 gateway。详情脚本补充 backend 入 path。

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-27 10:24:50 +08:00
parent 33bf73e3ba
commit 9fd32a3f84
19 changed files with 897 additions and 818 deletions

View File

@ -2,43 +2,23 @@
"""
从本地图片路径或图片 URL 调用 OpenAI 兼容多模态接口提取配料表等并提供**纯文本** ``chat/completions`` 供报告/策略等场景复用
**密钥与网关仅通过环境变量配置**勿写入代码
**实现** ``backend/pipeline/openai_gateway``本文件负责加载根目录 ``.env`` ``backend`` 加入 ``sys.path``
命令行试跑以及**同名符号**重导兼容历史 ``import AI_crawler``
- ``OPENAI_API_KEY``API Key必填
- ``OPENAI_BASE_URL``网关根地址 ``https://llm.example.com/v1``必填勿尾斜杠多余路径
- ``OPENAI_VISION_MODEL``视觉模型名可选默认 ``Qwen/Qwen3-Omni-30B-A3B``
环境变量说明见原仓库文档与 ``openai_gateway`` 模块注释
**纯文本调用**``chat_completion_text``优先使用
- ``OPENAI_TEXT_MODEL`` ``LLM_TEXT_MODEL``未设置时依次回退到 ``OPENAI_VISION_MODEL````LLM_MODEL``上述默认
兼容别名二选一即可``LLM_API_KEY````LLM_BASE_URL````LLM_MODEL``
- ``LLM_CONTEXT_WINDOW`` / ``OPENAI_CONTEXT_WINDOW``模型上下文 token 上限默认 ``32768````chat_completion_text`` 会按正文长度**保守估算**输入 token并把 ``max_tokens`` 收紧到不超过上限 估算输入避免网关报 ``max_tokens is too large`` LiteLLM ``ContextWindowExceededError``
上述变量与 Django 共用 **一份** ``market_assistant/.env``与本脚本所在 ``backend`` 的上三级目录下的 ``.env`` ``pip install python-dotenv``
**运行方式**在下方运行配置里改好 ``IMAGE_SOURCE`` 等变量后直接执行 ``python AI_crawler.py``无需命令行参数
**运行方式**在下方改 ``IMAGE_SOURCE`` 后执行 ``python AI_crawler.py``无需命令行参数
"""
from __future__ import annotations
import base64
import os
import re
import sys
from pathlib import Path
from typing import Any
import requests
_SCRIPT_DIR = Path(__file__).resolve().parent
# backend/crawler_copy/jd_pc_search -> parents[3] == market_assistant
_MA_ROOT = Path(__file__).resolve().parents[3]
def _load_market_assistant_dotenv() -> None:
"""先于 LOW_GI_PROJECT_ROOT 解析加载 ``market_assistant/.env``(唯一配置源)。"""
try:
from dotenv import load_dotenv
except ImportError:
@ -49,740 +29,57 @@ def _load_market_assistant_dotenv() -> None:
_load_market_assistant_dotenv()
_BACKEND = Path(__file__).resolve().parents[2]
if str(_BACKEND) not in sys.path:
sys.path.insert(0, str(_BACKEND))
from _low_gi_root import low_gi_project_root # noqa: E402
_PROJECT_ROOT = low_gi_project_root()
# ---------------------------------------------------------------------------
# 运行配置(按需修改;启动时不要求命令行参数)
# ---------------------------------------------------------------------------
# 必填:本地图片路径,或 http(s) 图片链接(如京东主图 / 详情图)
import requests # noqa: E402
from pipeline.openai_gateway import ( # noqa: E402
REASON_NO_BODY_URLS,
REASON_NO_VISION_API,
chat_completion_text,
extract_ingredients_from_body_image_urls_reversed,
extract_ingredients_from_body_image_urls_reversed_with_source,
extract_ingredients_from_image,
normalize_ingredients_text_for_csv,
normalize_message_content,
parse_joined_image_urls,
resolve_credentials,
resolve_text_model_name,
sanitize_vision_ingredients_output,
strip_outer_markdown_fence,
)
from pipeline.openai_gateway.constants import ( # noqa: E402
DEFAULT_MODEL,
DEFAULT_USER_AGENT,
)
from pipeline.openai_gateway.credentials import _resolve_credentials # noqa: E402
from pipeline.openai_gateway.ingredients_defaults import ( # noqa: E402
IMAGE_REFERER,
MAX_TOKENS,
PROMPT_DEFAULT,
QWEN_OMNI_TEMPLATE,
TEMPERATURE,
USER_PROMPT,
)
from pipeline.openai_gateway.ingredients_op import ( # noqa: E402
_ingredient_extraction_acceptable,
)
# 与早先脚本一致:供 ``python AI_crawler.py`` 单图试跑;多数字段与 ``ingredients_defaults`` 同义
IMAGE_SOURCE = "https://img30.360buyimg.com/sku/jfs/t1/390444/8/13018/103574/6982e951Fc44d9d7b/00d62ee56189d75d.jpg.avif"
# IMAGE_SOURCE = "https://img30.360buyimg.com/sku/jfs/t1/382894/31/7432/241977/694cf41aFa27be91e/00d63164ffeb8b46.jpg.avif"
# 提示词:留空则使用 ``PROMPT_DEFAULT``
USER_PROMPT = ""
PROMPT_DEFAULT = (
"请识别图片中的配料表,只输出配料列表本身,不要将菜品做法、步骤、用料等认为是配料表,不要误识别为食谱;用中文逗号或顿号分隔,"
"输出为连续一段文字,不要使用多行换行(避免与食谱、做法步骤混淆)。"
"每种原料名称只出现一次,禁止重复罗列同一添加剂(如磷酸三钾、磷酸三钠等勿循环抄写多遍);"
"若图为表格中多行同类添加剂,可概括为「食品添加剂(按国家标准使用)」或合并为一句,勿展开成数百字重复。"
"【禁止猜测】必须严格依据图中清晰可见的印刷文字归纳;不得根据商品品类、常识或模糊字迹推测、补全、编造任何原料。"
"若本图无配料表、仅有产品信息/广告、文字被裁切、过小、模糊到无法逐字确认,或你只能「猜」出部分内容,则禁止输出配料列表:"
"请只输出且仅输出一句「无法识别图片中的配料表」(不要解释、不要道歉长文、不要列出疑似项)。"
)
# 拉取远程图时的 Referer京东图床一般需类似商城域名
IMAGE_REFERER = "https://www.jd.com/"
TEMPERATURE = 0.0
MAX_TOKENS = 2048
# 部分 Qwen 网关需要关闭 thinking
QWEN_OMNI_TEMPLATE = False
# ---------------------------------------------------------------------------
DEFAULT_MODEL = "Qwen/Qwen3-Omni-30B-A3B"
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
def _normalize_chat_content(content: Any) -> str:
"""
兼容 OpenAI 兼容网关``message.content`` 可能是 str也可能是
``[{type:text, text:...}, ...]``避免对 list 误用 ``.strip()`` 或得到怪异字符串
"""
if content is None:
return ""
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict):
if item.get("type") == "text":
parts.append(str(item.get("text") or ""))
elif "text" in item:
parts.append(str(item.get("text") or ""))
elif isinstance(item, str):
parts.append(item)
return "".join(parts).strip()
return str(content).strip()
def normalize_ingredients_text_for_csv(text: str) -> str:
"""
将配料 OCR 结果压成**单行**便于 ``detail_ware_export.csv`` / 合并表展示
模型常按一行一项输出食谱或列表会产生多换行合并为非换行文本行间用中文分号分隔
"""
t = (text or "").replace("\r\n", "\n").replace("\r", "\n").strip()
if not t:
return ""
lines = [ln.strip() for ln in t.split("\n") if ln.strip()]
if len(lines) <= 1:
return lines[0] if lines else ""
return "".join(lines)
def _split_ingredient_segments(text: str) -> list[str]:
"""按常见分隔符拆成原料小段(用于检测尾部循环复读)。"""
t = (text or "").strip()
if not t:
return []
return [p.strip() for p in re.split(r"[;、,]+", t) if p.strip()]
def sanitize_vision_ingredients_output(text: str) -> str:
"""
清洗多模态配料识别结果去掉尾部引号切除仅两三种词循环数百次的模型复读尾巴超长截断
典型故障真实配料后无限重复磷酸三钾磷酸三钠仍因前半段通过业务校验
"""
t = (text or "").strip()
_trail_q = frozenset({'"', "'", "\u201c", "\u201d", "\u2018", "\u2019", "\uff02"})
while t and t[-1] in _trail_q:
t = t[:-1].strip()
segs = _split_ingredient_segments(t)
if not segs:
return ""
min_spam_run = 28
cut_i = len(segs)
for i in range(0, max(0, len(segs) - min_spam_run + 1)):
suf = segs[i:]
if len(suf) >= min_spam_run and len(set(suf)) <= 3:
cut_i = i
break
segs = segs[:cut_i]
if not segs:
return ""
t = "".join(segs)
# 字符级兜底:同一短词组高频重复(未按顿号切分时)
t = re.sub(
r"(磷酸三[钾钠][、,]?\s*){35,}",
"磷酸三钾、磷酸三钠等(按国家标准使用)",
t,
)
max_chars = 3200
if len(t) > max_chars:
cut = t[:max_chars]
last = max(cut.rfind(""), cut.rfind(""), cut.rfind(","), cut.rfind(""))
if last > max_chars // 2:
t = cut[: last + 1] + "…(已截断)"
else:
t = cut + "…(已截断)"
return t.strip()
def _resolve_credentials(
api_key: str | None,
base_url: str | None,
model: str | None,
) -> tuple[str, str, str]:
"""凭证只从环境变量(及可选函数参数)读取,不在代码中写死。"""
key = (
(api_key or "").strip()
or (os.environ.get("OPENAI_API_KEY") or os.environ.get("LLM_API_KEY") or "").strip()
)
base = (
(base_url or "").strip().rstrip("/")
or (
os.environ.get("OPENAI_BASE_URL") or os.environ.get("LLM_BASE_URL") or ""
).strip().rstrip("/")
)
m = (
(model or "").strip()
or (
os.environ.get("OPENAI_VISION_MODEL")
or os.environ.get("LLM_MODEL")
or DEFAULT_MODEL
).strip()
)
if not key:
raise ValueError("请设置环境变量 OPENAI_API_KEY或 LLM_API_KEY")
if not base:
raise ValueError(
"请设置环境变量 OPENAI_BASE_URL或 LLM_BASE_URL例如 https://your-gateway.com/v1"
)
return key, base, m
def resolve_text_model_name(model: str | None = None) -> str:
"""
文本补全所用模型显式 ``model`` 优先否则读环境变量见模块文档
"""
m = (model or "").strip()
if m:
return m
for env in (
"OPENAI_TEXT_MODEL",
"LLM_TEXT_MODEL",
"OPENAI_VISION_MODEL",
"LLM_MODEL",
):
v = (os.environ.get(env) or "").strip()
if v:
return v
return DEFAULT_MODEL
_normalize_chat_content = normalize_message_content
def _estimate_chat_input_tokens(system_prompt: str, user_prompt: str) -> int:
"""
保守估算 system+user token tiktoken 时用于约束 max_tokens
中文/JSON 往往高于英文 4 字符/token系数偏大宁可少给 max_tokens避免 400
"""
total_chars = len(system_prompt or "") + len(user_prompt or "")
return int(total_chars * 0.55) + 512
from pipeline.openai_gateway.estimate import estimate_chat_input_tokens
def strip_outer_markdown_fence(text: str) -> str:
"""若模型用 ``` / ```markdown 包裹全文,去掉最外层围栏。"""
t = (text or "").strip()
if not t.startswith("```"):
return t
lines = t.split("\n")
if lines and lines[0].strip().startswith("```"):
lines = lines[1:]
while lines and lines[-1].strip() == "```":
lines = lines[:-1]
return "\n".join(lines).strip()
def chat_completion_text(
*,
system_prompt: str,
user_prompt: str,
api_key: str | None = None,
base_url: str | None = None,
model: str | None = None,
temperature: float = 0.2,
max_tokens: int = 8192,
timeout: int = 300,
extra_json: dict[str, Any] | None = None,
) -> str:
"""
OpenAI 兼容网关的**纯文本**多轮占位为 system + user 各一条 ``extract_ingredients_from_image`` 共用凭证与端点
返回助手消息正文 ``strip`` / 兼容 list content
"""
k, b, _ = _resolve_credentials(api_key, base_url, None)
m = resolve_text_model_name(model)
body: dict[str, Any] = {
"model": m,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": temperature,
"max_tokens": max_tokens,
}
if extra_json:
body.update(extra_json)
# 避免 max_tokens 大于「上下文 输入」时网关 400如 LiteLLM ContextWindowExceededError
ctx_raw = (
os.environ.get("LLM_CONTEXT_WINDOW")
or os.environ.get("OPENAI_CONTEXT_WINDOW")
or "32768"
).strip()
try:
context_window = max(4096, int(ctx_raw))
except ValueError:
context_window = 32768
buf = 256
input_est = _estimate_chat_input_tokens(system_prompt, user_prompt)
if input_est >= context_window - buf - 256:
raise ValueError(
f"提示词过长(估算输入约 {input_est} tokens上下文上限 {context_window}"
"请缩小报告/摘要输入或换更大上下文的模型;也可设置环境变量 LLM_CONTEXT_WINDOW。"
)
avail = context_window - input_est - buf
want = int(body.get("max_tokens") or max_tokens)
body["max_tokens"] = max(256, min(want, max(avail, 256)))
r = requests.post(
f"{b}/chat/completions",
headers={
"Authorization": f"Bearer {k}",
"Content-Type": "application/json",
},
json=body,
timeout=timeout,
)
try:
r.raise_for_status()
except requests.HTTPError as e:
snippet = ""
if e.response is not None:
snippet = (e.response.text or "")[:1200].replace("\r\n", "\n").replace("\n", " ")
if snippet:
raise requests.HTTPError(
f"{e!s} | body: {snippet}",
response=e.response,
request=e.request,
) from e
raise
data = r.json()
msg = (data.get("choices") or [{}])[0].get("message") or {}
return _normalize_chat_content(msg.get("content"))
def _mime_for_path(path: str) -> str:
ext = path.lower().rsplit(".", 1)[-1]
return {
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"png": "image/png",
"webp": "image/webp",
"gif": "image/gif",
"avif": "image/avif",
}.get(ext, "image/jpeg")
def _mime_from_response(url: str, content_type: str | None) -> str:
if content_type and content_type.lower().startswith("image/"):
return content_type.split(";")[0].strip().lower()
u = url.lower().split("?")[0]
for suf, mime in (
(".png", "image/png"),
(".webp", "image/webp"),
(".avif", "image/avif"),
(".gif", "image/gif"),
(".jpg", "image/jpeg"),
(".jpeg", "image/jpeg"),
):
if u.endswith(suf):
return mime
return "image/jpeg"
def image_to_data_url(
source: str,
*,
referer: str = "https://www.jd.com/",
timeout: int = 60,
) -> tuple[str, str]:
"""
``source`` 为本地路径或以 http(s) 开头的 URL
返回 (data_url, 来源说明)
"""
s = source.strip()
if s.lower().startswith(("http://", "https://")):
headers = {
"User-Agent": DEFAULT_USER_AGENT,
"Accept": "image/avif,image/webp,image/*,*/*;q=0.8",
"Referer": referer,
}
r = requests.get(s, headers=headers, timeout=timeout, allow_redirects=True)
r.raise_for_status()
mime = _mime_from_response(s, r.headers.get("Content-Type"))
b64 = base64.standard_b64encode(r.content).decode("ascii")
return f"data:{mime};base64,{b64}", f"url:{s[:80]}"
with open(s, "rb") as f:
raw = f.read()
mime = _mime_for_path(s)
b64 = base64.standard_b64encode(raw).decode("ascii")
return f"data:{mime};base64,{b64}", f"file:{s}"
def extract_ingredients_from_image(
image_path_or_url: str,
*,
api_key: str | None = None,
base_url: str | None = None,
model: str | None = None,
user_prompt: str | None = None,
temperature: float = 0.0,
max_tokens: int = 2048,
referer: str = "https://www.jd.com/",
extra_json: dict[str, Any] | None = None,
prompt_default: str | None = None,
) -> str:
"""
从本地图片路径或图片 URL 识别配料表可改 ``user_prompt`` 扩展为营养成分表等
未传入 ``api_key`` / ``base_url`` / ``model`` 时从环境变量读取
返回值为经 ``normalize_ingredients_text_for_csv`` 处理后的**单行**文本便于写入 CSV
"""
k, b, m = _resolve_credentials(api_key, base_url, model)
data_url, _src = image_to_data_url(image_path_or_url, referer=referer)
_fallback = (
prompt_default
or "请识别图片中的配料表,只输出配料列表,不要误识别为做法用料;用逗号或顿号分隔为一段,不要换行分段。"
)
prompt = user_prompt if user_prompt is not None and str(user_prompt).strip() else _fallback
body: dict[str, Any] = {
"model": m,
"messages": [
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": data_url}},
{"type": "text", "text": prompt},
],
}
],
"temperature": temperature,
"max_tokens": max_tokens,
}
if extra_json:
body.update(extra_json)
r = requests.post(
f"{b}/chat/completions",
headers={
"Authorization": f"Bearer {k}",
"Content-Type": "application/json",
},
json=body,
timeout=120,
)
r.raise_for_status()
data = r.json()
msg = (data.get("choices") or [{}])[0].get("message") or {}
raw = normalize_ingredients_text_for_csv(_normalize_chat_content(msg.get("content")))
return sanitize_vision_ingredients_output(raw)
def parse_joined_image_urls(joined: str) -> list[str]:
"""
解析详情 DOM 拼出的 URL 与列 ``detail_body_ingredients`` URL阶段同形分号换行分隔的 http(s) 链接
保持从前到后的顺序去重不在这里做上游已去重
"""
t = (joined or "").strip()
if not t:
return []
t = t.replace("\r\n", "\n").replace("\r", "\n")
parts = re.split(r"\s*;\s*|\s*\n\s*", t)
out: list[str] = []
for p in parts:
u = p.strip()
if u.startswith(("http://", "https://")):
out.append(u)
return out
def _looks_like_recipe_or_dish_prep(text: str) -> bool:
"""
判断模型输出是否更像**菜谱/做法备料**详情图里常见而非包装配料表
命中则不应写入 ``detail_body_ingredients``继续尝试其它长图
"""
t = (text or "").strip()
if not t:
return False
recipe_kw = (
"做法",
"制作步骤",
"烹饪步骤",
"第一步",
"第二步",
"第三步",
"教程",
"准备食材",
"食材准备",
"下锅",
"翻炒",
"煮熟",
"大火烧开",
"转小火",
"装盘",
"小贴士",
"腌制",
"爆香",
"焯水",
"切丝",
"切丁",
"切片",
"打匀",
"搅拌均匀",
"油热",
"调味",
)
if any(k in t for k in recipe_kw):
return True
# 「葱花蒜末 各1勺」类菜谱用量
if re.search(r"各[一二两三四五六七八九十\d零]+勺", t):
return True
# 多条「短名称 + 数量 + 料理常用单位」并列(典型备料清单)
dish_qty = re.findall(
r"[^\n。,,、]{1,14}\s+\d+(?:\.\d+)?\s*[个只根块片勺条袋包杯碗适量克gG毫升mlML]{1,4}",
t,
)
if len(dish_qty) >= 2:
return True
# 半块/半根等家常分量词 + 生鲜食材名(包装配料表极少这样写)
if "半块" in t and re.search(r"鸡胸|鸡腿|牛肉|猪肉|黄瓜|番茄|土豆|豆腐", t):
return True
if "半根" in t and re.search(r"黄瓜|胡萝卜|玉米|香肠|葱", t):
return True
# 规范化后的「ABC…」若多段都很短且多段含数字多为做法用料枚举
parts = [p.strip() for p in t.split("") if p.strip()]
if len(parts) >= 4:
short_with_digit = [p for p in parts if len(p) <= 24 and re.search(r"\d", p)]
if len(short_with_digit) >= 4:
return True
# 多行/多段里至少 3 条「短句 + 数字 + 个根块勺克」
lines = [ln.strip() for ln in re.split(r"[\n]", t) if ln.strip()]
if len(lines) >= 3:
n_short_qty = sum(
1
for ln in lines
if len(ln) <= 22
and re.search(r"\d", ln)
and re.search(r"[个只根块片勺克gG]", ln)
)
if n_short_qty >= 3:
return True
return False
def _looks_like_packaged_ingredient_enumeration(text: str) -> bool:
"""
视觉模型常把包装图上的配料表整段压成**逗号/顿号分隔的原料枚举**丢掉标题与含量行
此类文本与菜谱备料鸡胸黄瓜葱花等可区分时视为有效配料信号
"""
t = (text or "").strip()
if not t:
return False
parts = [p.strip() for p in re.split(r"[,,、;]", t) if p.strip()]
if len(parts) < 3:
return False
# 多段像「家常备料」则不走此路(避免鸡胸、鸡蛋、黄瓜…误过)
recipe_seg = re.compile(
r"鸡胸|鸡腿|牛腩|牛肉|五花肉|里脊|鸡蛋|鸭蛋|皮蛋|黄瓜|番茄|西红柿|土豆|马铃薯|"
r"葱花|蒜末|姜丝|小米椒|青椒|洋葱|胡萝卜|生菜|菠菜|白菜|芹菜|香菜|小葱|"
r"面条$|挂面|粉条|粉丝"
)
n_recipe_like = sum(1 for p in parts if recipe_seg.search(p))
if n_recipe_like >= 2:
return False
# 工业化配料常见子串(粉体、纤维、添加剂类别、粮谷原料等)
industrial = re.compile(
r"食用|食品添加|麦麸|纤维|淀粉|魔芋|提取物|谷朊|谷胱|麸皮|糖浆|山梨|麦芽|柠檬酸|碳酸|"
r"酵母|乳粉|全脂|脱脂|果胶|黄原|卡拉胶|海藻酸|小麦|面粉|荞麦|燕麦|藜麦|青稞|糙米|黑米|"
r"棕榈|植物油|精炼油|氢化|起酥|可可脂"
)
n_industrial = sum(1 for p in parts if industrial.search(p))
if len(parts) >= 4 and n_industrial >= 2:
return True
if len(parts) >= 3 and n_industrial >= 3:
return True
return False
def _has_packaged_ingredient_table_signals(text: str) -> bool:
"""
正向判断是否像**包装配料表**标题+含量行内含量多段工业化原料枚举
OCR 出一段家常食材名无上述结构时返回 False
"""
t = (text or "").strip()
if not t:
return False
# 行内「××含量≥50%)」等,常见于包装,不强制出现「配料表」标题
if re.search(
r"[\u4e00-\u9fff\\d]{1,18}[(]\s*含量\s*[≥>=]?\s*[\d.]+\s*%?\s*[)]",
t,
):
return True
label = bool(
re.search(r"配料表", t)
or re.search(r"\s*料\s*[:]", t)
or re.search(r"\s*料\s*[:]", t)
or re.search(r"食品添加剂", t)
or re.search(r"产品\s*配\s*料", t)
)
# 「含量」相关信息百分比、不等式、法规用语、添加量表述等不含单独「50克」类菜谱用量
content = bool(
re.search(r"含量", t)
or re.search(r"添加量", t)
or re.search(r"\d+(?:\.\d+)?\s*[%]", t)
or re.search(r"[≥>>]\s*[\d.]+", t)
or re.search(r"\s*添\s*加\s*量\s*递\s*减", t)
)
if label and content:
return True
# 模型只输出「原料1,原料2,…」时仍可能是正规配料表
if _looks_like_packaged_ingredient_enumeration(t):
return True
return False
def _ingredient_extraction_acceptable(text: str) -> bool:
"""粗判模型输出是否像有效配料信息(过滤拒识句、伪列表、过短碎片、菜谱备料)。
通过条件之一配料表标题+含量类信号行内××含量x%或多段工业化原料枚举
``_looks_like_packaged_ingredient_enumeration``用于模型只输出逗号分隔原料丢掉标题时
"""
t = (text or "").strip()
if len(t) < 6:
return False
# 模型偶发输出类似 Python 列表的字符串,或 JSON 数组形态
if re.match(r"^\s*\[.*\]\s*$", t):
return False
refuse = (
"无法识别",
"没有配料",
"看不清",
"不存在配料",
"未在图中",
"未在图片",
"抱歉,我",
"抱歉,无法",
"不能识别",
"没有识别到",
"图中没有",
"图片中没有",
"无配料",
"未见配料",
)
if any(x in t for x in refuse):
return False
# 真配料表通常含分隔符或足够长;避免「无」「暂无」等被当成命中
if t in ("", "暂无", "没有", "无。", "无,"):
return False
if _looks_like_recipe_or_dish_prep(t):
return False
if not _has_packaged_ingredient_table_signals(t):
return False
tail = _split_ingredient_segments(t)
if len(tail) >= 32:
if len(set(tail[-32:])) <= 3:
return False
sep_chars = ",、,;"
if len(t) < 18 and not any(c in t for c in sep_chars):
return False
return True
REASON_NO_BODY_URLS = "【未识别到配料】未解析到任何详情长图 URL。"
REASON_NO_VISION_API = (
"【未识别到配料】未配置多模态 API需环境变量 OPENAI_API_KEY + OPENAI_BASE_URL"
"或 LLM_API_KEY + LLM_BASE_URL"
)
def extract_ingredients_from_body_image_urls_reversed_with_source(
urls_joined: str,
*,
referer: str | None = None,
user_prompt: str | None = None,
prompt_default: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
extra_json: dict[str, Any] | None = None,
) -> tuple[str, str | None]:
"""
``extract_ingredients_from_body_image_urls_reversed`` 相同逻辑额外返回命中配料时所用的**图片 URL**
自后向前首次通过校验的那张未命中或失败时第二项为 ``None``
"""
urls = parse_joined_image_urls(urls_joined)
if not urls:
return REASON_NO_BODY_URLS, None
try:
_resolve_credentials(None, None, None)
except ValueError:
return REASON_NO_VISION_API, None
ref = (referer if referer is not None else IMAGE_REFERER) or "https://www.jd.com/"
temp = float(temperature) if temperature is not None else float(TEMPERATURE)
mt = int(max_tokens) if max_tokens is not None else int(MAX_TOKENS)
extra = extra_json
if extra is None and QWEN_OMNI_TEMPLATE:
extra = {"chat_template_kwargs": {"enable_thinking": False}}
pu = user_prompt if user_prompt is not None else ((USER_PROMPT or "").strip() or None)
pd = prompt_default if prompt_default is not None else PROMPT_DEFAULT
n = len(urls)
n_err = 0
n_rejected = 0
for url in reversed(urls):
try:
text = extract_ingredients_from_image(
url,
user_prompt=pu,
referer=ref.strip(),
temperature=temp,
max_tokens=mt,
extra_json=extra,
prompt_default=pd,
)
except Exception:
n_err += 1
continue
t = (text or "").strip()
if _ingredient_extraction_acceptable(t):
return t, url
if t:
n_rejected += 1
parts = [
f"【未识别到配料】已对 {n} 张详情长图自后向前依次尝试(命中即停),未得到有效配料表。"
]
if n_err:
parts.append(f" 请求异常 {n_err} 次。")
if n_rejected:
parts.append(f"{n_rejected} 次返回未通过配料校验。")
if not n_err and not n_rejected:
parts.append(" 模型返回均为空或过短。")
return "".join(parts), None
def extract_ingredients_from_body_image_urls_reversed(
urls_joined: str,
*,
referer: str | None = None,
user_prompt: str | None = None,
prompt_default: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
extra_json: dict[str, Any] | None = None,
) -> str:
"""
URL 串拆出的链接 **从后往前**依次调用视觉模型**首次**通过校验的配料文本立即返回省时间
若始终无命中返回以 ``未识别到配料`` 开头的原因说明**不再返回空串**
未配置 API 时返回 ``REASON_NO_VISION_API``
命中条件 ``_ingredient_extraction_acceptable``须像**包装配料表**配料/含量标题结构
``××含量x%``或多段工业化原料逗号/顿号枚举模型常省略标题纯家常备料鸡胸黄瓜葱花等
仍丢弃并试下一张图
若需同时得到所用图片 URL请用 ``extract_ingredients_from_body_image_urls_reversed_with_source``
"""
text, _ = extract_ingredients_from_body_image_urls_reversed_with_source(
urls_joined,
referer=referer,
user_prompt=user_prompt,
prompt_default=prompt_default,
temperature=temperature,
max_tokens=max_tokens,
extra_json=extra_json,
)
return text
return estimate_chat_input_tokens(system_prompt, user_prompt)
def main() -> None:
@ -803,7 +100,7 @@ def main() -> None:
sys.exit(2)
prompt_use = (USER_PROMPT or "").strip() or None
extra = None
extra: dict[str, Any] | None = None
if QWEN_OMNI_TEMPLATE:
extra = {"chat_template_kwargs": {"enable_thinking": False}}

View File

@ -21,7 +21,7 @@
- ``OUTPUT_SKU_AND_BODY_IMAGES_ONLY=False``**原始接口 JSON** SKU ``OUT``批量 ``OUT_DIR`` / ``ware_{sku}.json``解析扁平含全部 ``detail_*``汇总表 ``OUT_PARSED_CSV``
**解析 API**``flatten_ware_business`` / ``parse_ware_business_response_text`` / ``ware_parsed_row``
``detail_body_ingredients`` 为配料表文本 ``#detail-main`` 长图经 ``AI_crawler`` 自后向前多模态识别);列 ``detail_body_ingredients_source_url`` 为**实际用于识别**的那张长图 URL命中即停。未配置 API 或识别失败时配料列为原因说明、图源列为空。内部 ``meta["detail_body_image_urls"]`` 仍为全部长图 URL 串,仅供解析用。
``detail_body_ingredients`` 为配料表文本 ``#detail-main`` 长图经 ``pipeline.openai_gateway`` 自后向前多模态识别);列 ``detail_body_ingredients_source_url`` 为**实际用于识别**的那张长图 URL命中即停。未配置 API 或识别失败时配料列为原因说明、图源列为空。内部 ``meta["detail_body_image_urls"]`` 仍为全部长图 URL 串,仅供解析用。
Cookie``../common/jd_cookie.txt``或配置项 ``COOKIE_FILE`` / ``COOKIE_OVERRIDE`` ``add_cookies`` 注入
@ -51,6 +51,10 @@ from playwright.sync_api import sync_playwright
_JD_PC_SEARCH = Path(__file__).resolve().parents[1]
if str(_JD_PC_SEARCH) not in sys.path:
sys.path.insert(0, str(_JD_PC_SEARCH))
# ``import pipeline.openai_gateway`` 需将 backend 根目录加入 pathDjango 启动时已有)
_backend_root = Path(__file__).resolve().parents[3]
if str(_backend_root) not in sys.path:
sys.path.insert(0, str(_backend_root))
from _low_gi_root import low_gi_project_root # noqa: E402
_PROJECT_ROOT = low_gi_project_root()
@ -92,7 +96,7 @@ LOG_API_M_JD_TRACE = False
# COLLECT_DETAIL_MAIN_IMAGE_URLSTrue 时在点击商品详情 tab 并等待后,从 #detail-main 抽取图文 URLstyle 中 background-image、img src、zbViewWeChatMiniImages
# 补全为 https 后写入 meta见 DETAIL_BODY_IMAGE_URL_SEPARATOR再经多模态写入列 detail_body_ingredients配料文本与 detail_body_ingredients_source_url命中图源
COLLECT_DETAIL_MAIN_IMAGE_URLS = True
# EXTRACT_INGREDIENTS_FROM_DETAIL_BODY_IMAGESTrue 时 detail_body_ingredients 为配料表文本、detail_body_ingredients_source_url 为命中图源False 时配料列恒为空、图源列恒为空。需 .env 中 OPENAI_* / LLM_*(见上级目录 AI_crawler.py
# EXTRACT_INGREDIENTS_FROM_DETAIL_BODY_IMAGESTrue 时 detail_body_ingredients 为配料表文本、detail_body_ingredients_source_url 为命中图源False 时配料列恒为空、图源列恒为空。需 .env 中 OPENAI_* / LLM_*(见 pipeline.openai_gateway
EXTRACT_INGREDIENTS_FROM_DETAIL_BODY_IMAGES = True
# DETAIL_BODY_IMAGE_URL_SEPARATOR写入 CSV/JSON 单单元格时的分隔符
DETAIL_BODY_IMAGE_URL_SEPARATOR = "; "
@ -348,9 +352,9 @@ def main() -> None:
vision_ok = False
if EXTRACT_INGREDIENTS_FROM_DETAIL_BODY_IMAGES:
try:
import AI_crawler as vision_mod # noqa: WPS433
import pipeline.openai_gateway as vision_mod # noqa: WPS433
vision_mod._resolve_credentials(None, None, None)
vision_mod.resolve_credentials(None, None, None)
vision_ok = True
except Exception as e:
print(

View File

@ -318,9 +318,9 @@ def main(keyword: str | None = None) -> Path:
_ingredient_vision_ok = False
if EXTRACT_INGREDIENTS_FROM_DETAIL_BODY_IMAGES:
try:
import AI_crawler as _ac_mod # noqa: WPS433
import pipeline.openai_gateway as _ac_mod # noqa: WPS433
_ac_mod._resolve_credentials(None, None, None)
_ac_mod.resolve_credentials(None, None, None)
_ingredient_vision_ok = True
except Exception as e:
print(

View File

@ -1,6 +1,6 @@
"""
竞品报告 / 策略稿的**大模型生成**通过 ``crawler_copy/jd_pc_search/AI_crawler``
``chat_completion_text`` 调用与配料识别共用网关与密钥
竞品报告 / 策略稿的**大模型生成**``pipeline.llm`` ``openai_gateway.chat_completion_text``OpenAI 兼容
与配料多模态识别共用 `OPENAI_*` / `LLM_*` 环境配置
实现已拆分为子模块``llm_client````generate_*``本模块保留对外符号以兼容
``from pipeline.llm.generate import `` 与测试中的 patch 路径

View File

@ -1,11 +1,10 @@
"""具体大模型通道实现:经统一协议暴露给 `factory`。"""
from __future__ import annotations
from .crawler_openai_compatible import CrawlerOpenAiCompatibleTextLlm, ensure_ai_crawler_path
from .crawler_openai_compatible import CrawlerOpenAiCompatibleTextLlm
from .openai_official_chatgpt import OpenAiOfficialChatGptTextLlm
__all__ = [
"CrawlerOpenAiCompatibleTextLlm",
"OpenAiOfficialChatGptTextLlm",
"ensure_ai_crawler_path",
]

View File

@ -1,26 +1,15 @@
"""
`crawler_copy/jd_pc_search/AI_crawler.chat_completion_text` 访问 OpenAI 兼容网关与配料识别等共用凭据与配置
``pipeline.openai_gateway.text_chat.chat_completion_text`` 访问 OpenAI 兼容网关与配料识别等共用环境变量不再 import 爬虫目录
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from django.conf import settings
from pipeline.openai_gateway import chat_completion_text
from ..shared.token_heuristics import estimate_crawler_style_input_tokens
def ensure_ai_crawler_path() -> None:
root = Path(settings.CRAWLER_JD_ROOT).resolve()
if not root.is_dir():
raise FileNotFoundError(f"爬虫副本目录不存在: {root}")
rs = str(root)
if rs not in sys.path:
sys.path.insert(0, rs)
def _llm_context_window_size_from_env() -> int:
raw = (
os.environ.get("LLM_CONTEXT_WINDOW")
@ -35,8 +24,7 @@ def _llm_context_window_size_from_env() -> int:
class CrawlerOpenAiCompatibleTextLlm:
"""
文本任务默认后端复用 `AI_crawler` `chat_completion_text` 与上下文预检逻辑
更换为其它云厂商时应新增独立适配器并在 `factory` 中注册而非修改本类
文本任务默认后端与历史 ``AI_crawler.chat_completion_text`` 行为一致实现位于 ``pipeline.openai_gateway``
"""
def complete_text(
@ -46,16 +34,13 @@ class CrawlerOpenAiCompatibleTextLlm:
*,
temperature: float | None = None,
) -> str:
ensure_ai_crawler_path()
import AI_crawler as ac # noqa: WPS433
kwargs: dict[str, object] = {
"system_prompt": system_prompt,
"user_prompt": user_prompt,
}
if temperature is not None:
kwargs["temperature"] = float(temperature)
return ac.chat_completion_text(**kwargs)
return chat_completion_text(**kwargs)
def estimate_input_tokens(self, system_prompt: str, user_prompt: str) -> int:
return estimate_crawler_style_input_tokens(system_prompt, user_prompt)

View File

@ -11,8 +11,10 @@ from typing import Any
import requests
from ..shared.openai_message_content import normalize_message_content
from ..shared.token_heuristics import estimate_crawler_style_input_tokens
from pipeline.openai_gateway.chat_content import normalize_message_content
from pipeline.openai_gateway.estimate import (
estimate_chat_input_tokens as estimate_crawler_style_input_tokens,
)
_DEFAULT_BASE = "https://api.openai.com/v1"
_DEFAULT_MODEL = "gpt-4o-mini"

View File

@ -1,26 +1,8 @@
"""
解析 OpenAI chat.completions 返回的 `message.content`可能是 str part 列表
`AI_crawler._normalize_chat_content` 行为一致
解析 OpenAI `message.content`实现位于 ``pipeline.openai_gateway.chat_content``仅重导以保持与旧 import 路径兼容
"""
from __future__ import annotations
from typing import Any
from pipeline.openai_gateway.chat_content import normalize_message_content
def normalize_message_content(content: Any) -> str:
if content is None:
return ""
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict):
if item.get("type") == "text":
parts.append(str(item.get("text") or ""))
elif "text" in item:
parts.append(str(item.get("text") or ""))
elif isinstance(item, str):
parts.append(item)
return "".join(parts).strip()
return str(content).strip()
__all__ = ["normalize_message_content"]

View File

@ -1,15 +1,6 @@
"""对模型原始输出做与业务无关的轻量归一化(不修改提示词)"""
"""对模型输出去围栏等;与 ``openai_gateway.text_chat.strip_outer_markdown_fence`` 同义"""
from __future__ import annotations
from pipeline.openai_gateway.text_chat import strip_outer_markdown_fence
def strip_outer_markdown_fence(text: str) -> str:
"""若模型用 ``` / ```markdown 包裹全文,去掉最外层围栏。与 `AI_crawler.strip_outer_markdown_fence` 行为一致。"""
t = (text or "").strip()
if not t.startswith("```"):
return t
lines = t.split("\n")
if lines and lines[0].strip().startswith("```"):
lines = lines[1:]
while lines and lines[-1].strip() == "```":
lines = lines[:-1]
return "\n".join(lines).strip()
__all__ = ["strip_outer_markdown_fence"]

View File

@ -1,10 +1,8 @@
"""
`crawler_copy/.../AI_crawler` `_estimate_chat_input_tokens` 同口径的保守估算
供预检策略档位与各适配器共用 tiktoken 时避免 max_tokens 400
``pipeline.openai_gateway.estimate.estimate_chat_input_tokens`` 同口径的保守估算
"""
from __future__ import annotations
from pipeline.openai_gateway.estimate import estimate_chat_input_tokens as estimate_crawler_style_input_tokens
def estimate_crawler_style_input_tokens(system_prompt: str, user_prompt: str) -> int:
total_chars = len(system_prompt or "") + len(user_prompt or "")
return int(total_chars * 0.55) + 512
__all__ = ["estimate_crawler_style_input_tokens"]

View File

@ -0,0 +1,42 @@
"""
OpenAI 兼容网关`chat/completions`纯文本多模态配料详情长图逆序等
``crawler_copy/.../AI_crawler`` 解耦逻辑唯一来源在 ``pipeline.openai_gateway``
脚本 ``AI_crawler.py`` 仅作加载 .env 与命令行试跑并向后兼容重导出名
"""
from __future__ import annotations
from .chat_content import normalize_message_content
from .credentials import (
_resolve_credentials,
resolve_credentials,
resolve_text_model_name,
)
from .ingredients_op import (
REASON_NO_BODY_URLS,
REASON_NO_VISION_API,
extract_ingredients_from_body_image_urls_reversed,
extract_ingredients_from_body_image_urls_reversed_with_source,
extract_ingredients_from_image,
normalize_ingredients_text_for_csv,
parse_joined_image_urls,
sanitize_vision_ingredients_output,
)
from .text_chat import chat_completion_text, strip_outer_markdown_fence
__all__ = [
"REASON_NO_BODY_URLS",
"REASON_NO_VISION_API",
"_resolve_credentials",
"chat_completion_text",
"extract_ingredients_from_body_image_urls_reversed",
"extract_ingredients_from_body_image_urls_reversed_with_source",
"extract_ingredients_from_image",
"normalize_ingredients_text_for_csv",
"normalize_message_content",
"parse_joined_image_urls",
"resolve_credentials",
"resolve_text_model_name",
"sanitize_vision_ingredients_output",
"strip_outer_markdown_fence",
]

View File

@ -0,0 +1,23 @@
"""解析 `chat/completions` 返回的 `message.content`str 或多段)。"""
from __future__ import annotations
from typing import Any
def normalize_message_content(content: Any) -> str:
if content is None:
return ""
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict):
if item.get("type") == "text":
parts.append(str(item.get("text") or ""))
elif "text" in item:
parts.append(str(item.get("text") or ""))
elif isinstance(item, str):
parts.append(item)
return "".join(parts).strip()
return str(content).strip()

View File

@ -0,0 +1,8 @@
"""OpenAI 兼容调用的缺省常数(可仍由环境变量覆盖)。"""
from __future__ import annotations
DEFAULT_MODEL = "Qwen/Qwen3-Omni-30B-A3B"
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)

View File

@ -0,0 +1,63 @@
"""OpenAI 兼容网关:从环境或参数解析 API 凭证与模型名(文本 / 多模态共用)。"""
from __future__ import annotations
import os
from .constants import DEFAULT_MODEL
def _resolve_credentials(
api_key: str | None,
base_url: str | None,
model: str | None,
) -> tuple[str, str, str]:
key = (
(api_key or "").strip()
or (os.environ.get("OPENAI_API_KEY") or os.environ.get("LLM_API_KEY") or "").strip()
)
base = (
(base_url or "").strip().rstrip("/")
or (
os.environ.get("OPENAI_BASE_URL") or os.environ.get("LLM_BASE_URL") or ""
).strip().rstrip("/")
)
m = (
(model or "").strip()
or (
os.environ.get("OPENAI_VISION_MODEL")
or os.environ.get("LLM_MODEL")
or DEFAULT_MODEL
).strip()
)
if not key:
raise ValueError("请设置环境变量 OPENAI_API_KEY或 LLM_API_KEY")
if not base:
raise ValueError(
"请设置环境变量 OPENAI_BASE_URL或 LLM_BASE_URL例如 https://your-gateway.com/v1"
)
return key, base, m
def resolve_text_model_name(model: str | None = None) -> str:
m = (model or "").strip()
if m:
return m
for env in (
"OPENAI_TEXT_MODEL",
"LLM_TEXT_MODEL",
"OPENAI_VISION_MODEL",
"LLM_MODEL",
):
v = (os.environ.get(env) or "").strip()
if v:
return v
return DEFAULT_MODEL
def resolve_credentials(
api_key: str | None = None,
base_url: str | None = None,
model: str | None = None,
) -> tuple[str, str, str]:
"""与历史 ``AI_crawler._resolve_credentials`` 行为一致,供业务显式预检(如多模态是否可用)。"""
return _resolve_credentials(api_key, base_url, model)

View File

@ -0,0 +1,7 @@
"""输入 token 保守估算(与历史 AI_crawler 同口径)。"""
from __future__ import annotations
def estimate_chat_input_tokens(system_prompt: str, user_prompt: str) -> int:
total_chars = len(system_prompt or "") + len(user_prompt or "")
return int(total_chars * 0.55) + 512

View File

@ -0,0 +1,20 @@
"""详情页配料多模态识别:提示词、温度与 Referer 等(与 `AI_crawler` 顶部运行配置同口径)。"""
from __future__ import annotations
# 与 AI_crawler 中 PROMPT_DEFAULT 保持同步(脚本入口仍可在本地覆盖)
IMAGE_REFERER = "https://www.jd.com/"
USER_PROMPT = ""
PROMPT_DEFAULT = (
"请识别图片中的配料表,只输出配料列表本身,不要将菜品做法、步骤、用料等认为是配料表,不要误识别为食谱;用中文逗号或顿号分隔,"
"输出为连续一段文字,不要使用多行换行(避免与食谱、做法步骤混淆)。"
"每种原料名称只出现一次,禁止重复罗列同一添加剂(如磷酸三钾、磷酸三钠等勿循环抄写多遍);"
"若图为表格中多行同类添加剂,可概括为「食品添加剂(按国家标准使用)」或合并为一句,勿展开成数百字重复。"
"【禁止猜测】必须严格依据图中清晰可见的印刷文字归纳;不得根据商品品类、常识或模糊字迹推测、补全、编造任何原料。"
"若本图无配料表、仅有产品信息/广告、文字被裁切、过小、模糊到无法逐字确认,或你只能「猜」出部分内容,则禁止输出配料列表:"
"请只输出且仅输出一句「无法识别图片中的配料表」(不要解释、不要道歉长文、不要列出疑似项)。"
)
TEMPERATURE = 0.0
MAX_TOKENS = 2048
# 部分 Qwen 网关需要关闭 thinking
QWEN_OMNI_TEMPLATE = False

View File

@ -0,0 +1,536 @@
"""配料识别与详情长图逆序等逻辑。由 `AI_crawler` 抽离至 `pipeline.openai_gateway`。"""
from __future__ import annotations
import base64
import re
from typing import Any
import requests
from . import ingredients_defaults as _d
from .chat_content import normalize_message_content as _normalize_chat_content
from .constants import DEFAULT_USER_AGENT
from .credentials import _resolve_credentials
def normalize_ingredients_text_for_csv(text: str) -> str:
"""
将配料 OCR 结果压成**单行**便于 ``detail_ware_export.csv`` / 合并表展示
模型常按一行一项输出食谱或列表会产生多换行合并为非换行文本行间用中文分号分隔
"""
t = (text or "").replace("\r\n", "\n").replace("\r", "\n").strip()
if not t:
return ""
lines = [ln.strip() for ln in t.split("\n") if ln.strip()]
if len(lines) <= 1:
return lines[0] if lines else ""
return "".join(lines)
def _split_ingredient_segments(text: str) -> list[str]:
"""按常见分隔符拆成原料小段(用于检测尾部循环复读)。"""
t = (text or "").strip()
if not t:
return []
return [p.strip() for p in re.split(r"[;、,]+", t) if p.strip()]
def sanitize_vision_ingredients_output(text: str) -> str:
"""
清洗多模态配料识别结果去掉尾部引号切除仅两三种词循环数百次的模型复读尾巴超长截断
典型故障真实配料后无限重复磷酸三钾磷酸三钠仍因前半段通过业务校验
"""
t = (text or "").strip()
_trail_q = frozenset({'"', "'", "\u201c", "\u201d", "\u2018", "\u2019", "\uff02"})
while t and t[-1] in _trail_q:
t = t[:-1].strip()
segs = _split_ingredient_segments(t)
if not segs:
return ""
min_spam_run = 28
cut_i = len(segs)
for i in range(0, max(0, len(segs) - min_spam_run + 1)):
suf = segs[i:]
if len(suf) >= min_spam_run and len(set(suf)) <= 3:
cut_i = i
break
segs = segs[:cut_i]
if not segs:
return ""
t = "".join(segs)
# 字符级兜底:同一短词组高频重复(未按顿号切分时)
t = re.sub(
r"(磷酸三[钾钠][、,]?\s*){35,}",
"磷酸三钾、磷酸三钠等(按国家标准使用)",
t,
)
max_chars = 3200
if len(t) > max_chars:
cut = t[:max_chars]
last = max(cut.rfind(""), cut.rfind(""), cut.rfind(","), cut.rfind(""))
if last > max_chars // 2:
t = cut[: last + 1] + "…(已截断)"
else:
t = cut + "…(已截断)"
return t.strip()
def _mime_for_path(path: str) -> str:
ext = path.lower().rsplit(".", 1)[-1]
return {
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"png": "image/png",
"webp": "image/webp",
"gif": "image/gif",
"avif": "image/avif",
}.get(ext, "image/jpeg")
def _mime_from_response(url: str, content_type: str | None) -> str:
if content_type and content_type.lower().startswith("image/"):
return content_type.split(";")[0].strip().lower()
u = url.lower().split("?")[0]
for suf, mime in (
(".png", "image/png"),
(".webp", "image/webp"),
(".avif", "image/avif"),
(".gif", "image/gif"),
(".jpg", "image/jpeg"),
(".jpeg", "image/jpeg"),
):
if u.endswith(suf):
return mime
return "image/jpeg"
def image_to_data_url(
source: str,
*,
referer: str = "https://www.jd.com/",
timeout: int = 60,
) -> tuple[str, str]:
"""
``source`` 为本地路径或以 http(s) 开头的 URL
返回 (data_url, 来源说明)
"""
s = source.strip()
if s.lower().startswith(("http://", "https://")):
headers = {
"User-Agent": DEFAULT_USER_AGENT,
"Accept": "image/avif,image/webp,image/*,*/*;q=0.8",
"Referer": referer,
}
r = requests.get(s, headers=headers, timeout=timeout, allow_redirects=True)
r.raise_for_status()
mime = _mime_from_response(s, r.headers.get("Content-Type"))
b64 = base64.standard_b64encode(r.content).decode("ascii")
return f"data:{mime};base64,{b64}", f"url:{s[:80]}"
with open(s, "rb") as f:
raw = f.read()
mime = _mime_for_path(s)
b64 = base64.standard_b64encode(raw).decode("ascii")
return f"data:{mime};base64,{b64}", f"file:{s}"
def extract_ingredients_from_image(
image_path_or_url: str,
*,
api_key: str | None = None,
base_url: str | None = None,
model: str | None = None,
user_prompt: str | None = None,
temperature: float = 0.0,
max_tokens: int = 2048,
referer: str = "https://www.jd.com/",
extra_json: dict[str, Any] | None = None,
prompt_default: str | None = None,
) -> str:
"""
从本地图片路径或图片 URL 识别配料表可改 ``user_prompt`` 扩展为营养成分表等
未传入 ``api_key`` / ``base_url`` / ``model`` 时从环境变量读取
返回值为经 ``normalize_ingredients_text_for_csv`` 处理后的**单行**文本便于写入 CSV
"""
k, b, m = _resolve_credentials(api_key, base_url, model)
data_url, _src = image_to_data_url(image_path_or_url, referer=referer)
_fallback = (
prompt_default
or "请识别图片中的配料表,只输出配料列表,不要误识别为做法用料;用逗号或顿号分隔为一段,不要换行分段。"
)
prompt = user_prompt if user_prompt is not None and str(user_prompt).strip() else _fallback
body: dict[str, Any] = {
"model": m,
"messages": [
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": data_url}},
{"type": "text", "text": prompt},
],
}
],
"temperature": temperature,
"max_tokens": max_tokens,
}
if extra_json:
body.update(extra_json)
r = requests.post(
f"{b}/chat/completions",
headers={
"Authorization": f"Bearer {k}",
"Content-Type": "application/json",
},
json=body,
timeout=120,
)
r.raise_for_status()
data = r.json()
msg = (data.get("choices") or [{}])[0].get("message") or {}
raw = normalize_ingredients_text_for_csv(_normalize_chat_content(msg.get("content")))
return sanitize_vision_ingredients_output(raw)
def parse_joined_image_urls(joined: str) -> list[str]:
"""
解析详情 DOM 拼出的 URL 与列 ``detail_body_ingredients`` URL阶段同形分号换行分隔的 http(s) 链接
保持从前到后的顺序去重不在这里做上游已去重
"""
t = (joined or "").strip()
if not t:
return []
t = t.replace("\r\n", "\n").replace("\r", "\n")
parts = re.split(r"\s*;\s*|\s*\n\s*", t)
out: list[str] = []
for p in parts:
u = p.strip()
if u.startswith(("http://", "https://")):
out.append(u)
return out
def _looks_like_recipe_or_dish_prep(text: str) -> bool:
"""
判断模型输出是否更像**菜谱/做法备料**详情图里常见而非包装配料表
命中则不应写入 ``detail_body_ingredients``继续尝试其它长图
"""
t = (text or "").strip()
if not t:
return False
recipe_kw = (
"做法",
"制作步骤",
"烹饪步骤",
"第一步",
"第二步",
"第三步",
"教程",
"准备食材",
"食材准备",
"下锅",
"翻炒",
"煮熟",
"大火烧开",
"转小火",
"装盘",
"小贴士",
"腌制",
"爆香",
"焯水",
"切丝",
"切丁",
"切片",
"打匀",
"搅拌均匀",
"油热",
"调味",
)
if any(k in t for k in recipe_kw):
return True
# 「葱花蒜末 各1勺」类菜谱用量
if re.search(r"各[一二两三四五六七八九十\d零]+勺", t):
return True
# 多条「短名称 + 数量 + 料理常用单位」并列(典型备料清单)
dish_qty = re.findall(
r"[^\n。,,、]{1,14}\s+\d+(?:\.\d+)?\s*[个只根块片勺条袋包杯碗适量克gG毫升mlML]{1,4}",
t,
)
if len(dish_qty) >= 2:
return True
# 半块/半根等家常分量词 + 生鲜食材名(包装配料表极少这样写)
if "半块" in t and re.search(r"鸡胸|鸡腿|牛肉|猪肉|黄瓜|番茄|土豆|豆腐", t):
return True
if "半根" in t and re.search(r"黄瓜|胡萝卜|玉米|香肠|葱", t):
return True
# 规范化后的「ABC…」若多段都很短且多段含数字多为做法用料枚举
parts = [p.strip() for p in t.split("") if p.strip()]
if len(parts) >= 4:
short_with_digit = [p for p in parts if len(p) <= 24 and re.search(r"\d", p)]
if len(short_with_digit) >= 4:
return True
# 多行/多段里至少 3 条「短句 + 数字 + 个根块勺克」
lines = [ln.strip() for ln in re.split(r"[\n]", t) if ln.strip()]
if len(lines) >= 3:
n_short_qty = sum(
1
for ln in lines
if len(ln) <= 22
and re.search(r"\d", ln)
and re.search(r"[个只根块片勺克gG]", ln)
)
if n_short_qty >= 3:
return True
return False
def _looks_like_packaged_ingredient_enumeration(text: str) -> bool:
"""
视觉模型常把包装图上的配料表整段压成**逗号/顿号分隔的原料枚举**丢掉标题与含量行
此类文本与菜谱备料鸡胸黄瓜葱花等可区分时视为有效配料信号
"""
t = (text or "").strip()
if not t:
return False
parts = [p.strip() for p in re.split(r"[,,、;]", t) if p.strip()]
if len(parts) < 3:
return False
# 多段像「家常备料」则不走此路(避免鸡胸、鸡蛋、黄瓜…误过)
recipe_seg = re.compile(
r"鸡胸|鸡腿|牛腩|牛肉|五花肉|里脊|鸡蛋|鸭蛋|皮蛋|黄瓜|番茄|西红柿|土豆|马铃薯|"
r"葱花|蒜末|姜丝|小米椒|青椒|洋葱|胡萝卜|生菜|菠菜|白菜|芹菜|香菜|小葱|"
r"面条$|挂面|粉条|粉丝"
)
n_recipe_like = sum(1 for p in parts if recipe_seg.search(p))
if n_recipe_like >= 2:
return False
# 工业化配料常见子串(粉体、纤维、添加剂类别、粮谷原料等)
industrial = re.compile(
r"食用|食品添加|麦麸|纤维|淀粉|魔芋|提取物|谷朊|谷胱|麸皮|糖浆|山梨|麦芽|柠檬酸|碳酸|"
r"酵母|乳粉|全脂|脱脂|果胶|黄原|卡拉胶|海藻酸|小麦|面粉|荞麦|燕麦|藜麦|青稞|糙米|黑米|"
r"棕榈|植物油|精炼油|氢化|起酥|可可脂"
)
n_industrial = sum(1 for p in parts if industrial.search(p))
if len(parts) >= 4 and n_industrial >= 2:
return True
if len(parts) >= 3 and n_industrial >= 3:
return True
return False
def _has_packaged_ingredient_table_signals(text: str) -> bool:
"""
正向判断是否像**包装配料表**标题+含量行内含量多段工业化原料枚举
OCR 出一段家常食材名无上述结构时返回 False
"""
t = (text or "").strip()
if not t:
return False
# 行内「××含量≥50%)」等,常见于包装,不强制出现「配料表」标题
if re.search(
r"[\u4e00-\u9fff\\d]{1,18}[(]\s*含量\s*[≥>=]?\s*[\d.]+\s*%?\s*[)]",
t,
):
return True
label = bool(
re.search(r"配料表", t)
or re.search(r"\s*料\s*[:]", t)
or re.search(r"\s*料\s*[:]", t)
or re.search(r"食品添加剂", t)
or re.search(r"产品\s*配\s*料", t)
)
# 「含量」相关信息百分比、不等式、法规用语、添加量表述等不含单独「50克」类菜谱用量
content = bool(
re.search(r"含量", t)
or re.search(r"添加量", t)
or re.search(r"\d+(?:\.\d+)?\s*[%]", t)
or re.search(r"[≥>>]\s*[\d.]+", t)
or re.search(r"\s*添\s*加\s*量\s*递\s*减", t)
)
if label and content:
return True
# 模型只输出「原料1,原料2,…」时仍可能是正规配料表
if _looks_like_packaged_ingredient_enumeration(t):
return True
return False
def _ingredient_extraction_acceptable(text: str) -> bool:
"""粗判模型输出是否像有效配料信息(过滤拒识句、伪列表、过短碎片、菜谱备料)。
通过条件之一配料表标题+含量类信号行内××含量x%或多段工业化原料枚举
``_looks_like_packaged_ingredient_enumeration``用于模型只输出逗号分隔原料丢掉标题时
"""
t = (text or "").strip()
if len(t) < 6:
return False
# 模型偶发输出类似 Python 列表的字符串,或 JSON 数组形态
if re.match(r"^\s*\[.*\]\s*$", t):
return False
refuse = (
"无法识别",
"没有配料",
"看不清",
"不存在配料",
"未在图中",
"未在图片",
"抱歉,我",
"抱歉,无法",
"不能识别",
"没有识别到",
"图中没有",
"图片中没有",
"无配料",
"未见配料",
)
if any(x in t for x in refuse):
return False
# 真配料表通常含分隔符或足够长;避免「无」「暂无」等被当成命中
if t in ("", "暂无", "没有", "无。", "无,"):
return False
if _looks_like_recipe_or_dish_prep(t):
return False
if not _has_packaged_ingredient_table_signals(t):
return False
tail = _split_ingredient_segments(t)
if len(tail) >= 32:
if len(set(tail[-32:])) <= 3:
return False
sep_chars = ",、,;"
if len(t) < 18 and not any(c in t for c in sep_chars):
return False
return True
REASON_NO_BODY_URLS = "【未识别到配料】未解析到任何详情长图 URL。"
REASON_NO_VISION_API = (
"【未识别到配料】未配置多模态 API需环境变量 OPENAI_API_KEY + OPENAI_BASE_URL"
"或 LLM_API_KEY + LLM_BASE_URL"
)
def extract_ingredients_from_body_image_urls_reversed_with_source(
urls_joined: str,
*,
referer: str | None = None,
user_prompt: str | None = None,
prompt_default: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
extra_json: dict[str, Any] | None = None,
) -> tuple[str, str | None]:
"""
``extract_ingredients_from_body_image_urls_reversed`` 相同逻辑额外返回命中配料时所用的**图片 URL**
自后向前首次通过校验的那张未命中或失败时第二项为 ``None``
"""
urls = parse_joined_image_urls(urls_joined)
if not urls:
return REASON_NO_BODY_URLS, None
try:
_resolve_credentials(None, None, None)
except ValueError:
return REASON_NO_VISION_API, None
ref = (referer if referer is not None else _d.IMAGE_REFERER) or "https://www.jd.com/"
temp = float(temperature) if temperature is not None else float(_d.TEMPERATURE)
mt = int(max_tokens) if max_tokens is not None else int(_d.MAX_TOKENS)
extra = extra_json
if extra is None and _d.QWEN_OMNI_TEMPLATE:
extra = {"chat_template_kwargs": {"enable_thinking": False}}
pu = user_prompt if user_prompt is not None else ((_d.USER_PROMPT or "").strip() or None)
pd = prompt_default if prompt_default is not None else _d.PROMPT_DEFAULT
n = len(urls)
n_err = 0
n_rejected = 0
for url in reversed(urls):
try:
text = extract_ingredients_from_image(
url,
user_prompt=pu,
referer=ref.strip(),
temperature=temp,
max_tokens=mt,
extra_json=extra,
prompt_default=pd,
)
except Exception:
n_err += 1
continue
t = (text or "").strip()
if _ingredient_extraction_acceptable(t):
return t, url
if t:
n_rejected += 1
parts = [
f"【未识别到配料】已对 {n} 张详情长图自后向前依次尝试(命中即停),未得到有效配料表。"
]
if n_err:
parts.append(f" 请求异常 {n_err} 次。")
if n_rejected:
parts.append(f"{n_rejected} 次返回未通过配料校验。")
if not n_err and not n_rejected:
parts.append(" 模型返回均为空或过短。")
return "".join(parts), None
def extract_ingredients_from_body_image_urls_reversed(
urls_joined: str,
*,
referer: str | None = None,
user_prompt: str | None = None,
prompt_default: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
extra_json: dict[str, Any] | None = None,
) -> str:
"""
URL 串拆出的链接 **从后往前**依次调用视觉模型**首次**通过校验的配料文本立即返回省时间
若始终无命中返回以 ``未识别到配料`` 开头的原因说明**不再返回空串**
未配置 API 时返回 ``REASON_NO_VISION_API``
命中条件 ``_ingredient_extraction_acceptable``须像**包装配料表**配料/含量标题结构
``××含量x%``或多段工业化原料逗号/顿号枚举模型常省略标题纯家常备料鸡胸黄瓜葱花等
仍丢弃并试下一张图
若需同时得到所用图片 URL请用 ``extract_ingredients_from_body_image_urls_reversed_with_source``
"""
text, _ = extract_ingredients_from_body_image_urls_reversed_with_source(
urls_joined,
referer=referer,
user_prompt=user_prompt,
prompt_default=prompt_default,
temperature=temperature,
max_tokens=max_tokens,
extra_json=extra_json,
)
return text

View File

@ -0,0 +1,100 @@
"""
OpenAI 兼容 `chat/completions` 纯文本system + user与多模态配料识别共用 `OPENAI_*` / `LLM_*` 环境配置
"""
from __future__ import annotations
import os
from typing import Any
import requests
from .chat_content import normalize_message_content
from .credentials import _resolve_credentials, resolve_text_model_name
from .estimate import estimate_chat_input_tokens
from .timeouts import chat_completion_read_timeout as _chat_completion_timeout
def strip_outer_markdown_fence(text: str) -> str:
"""若模型用 ``` / ```markdown 包裹全文,去掉最外层围栏。"""
t = (text or "").strip()
if not t.startswith("```"):
return t
lines = t.split("\n")
if lines and lines[0].strip().startswith("```"):
lines = lines[1:]
while lines and lines[-1].strip() == "```":
lines = lines[:-1]
return "\n".join(lines).strip()
def chat_completion_text(
*,
system_prompt: str,
user_prompt: str,
api_key: str | None = None,
base_url: str | None = None,
model: str | None = None,
temperature: float = 0.2,
max_tokens: int = 8192,
timeout: int | tuple[float, float] | None = None,
extra_json: dict[str, Any] | None = None,
) -> str:
if timeout is None:
timeout = _chat_completion_timeout()
k, b, _ = _resolve_credentials(api_key, base_url, None)
m = resolve_text_model_name(model)
body: dict[str, Any] = {
"model": m,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": temperature,
"max_tokens": max_tokens,
}
if extra_json:
body.update(extra_json)
ctx_raw = (
os.environ.get("LLM_CONTEXT_WINDOW")
or os.environ.get("OPENAI_CONTEXT_WINDOW")
or "32768"
).strip()
try:
context_window = max(4096, int(ctx_raw))
except ValueError:
context_window = 32768
buf = 256
input_est = estimate_chat_input_tokens(system_prompt, user_prompt)
if input_est >= context_window - buf - 256:
raise ValueError(
f"提示词过长(估算输入约 {input_est} tokens上下文上限 {context_window}"
"请缩小报告/摘要输入或换更大上下文的模型;也可设置环境变量 LLM_CONTEXT_WINDOW。"
)
avail = context_window - input_est - buf
want = int(body.get("max_tokens") or max_tokens)
body["max_tokens"] = max(256, min(want, max(avail, 256)))
r = requests.post(
f"{b}/chat/completions",
headers={
"Authorization": f"Bearer {k}",
"Content-Type": "application/json",
},
json=body,
timeout=timeout,
)
try:
r.raise_for_status()
except requests.HTTPError as e:
snippet = ""
if e.response is not None:
snippet = (e.response.text or "")[:1200].replace("\r\n", "\n").replace("\n", " ")
if snippet:
raise requests.HTTPError(
f"{e!s} | body: {snippet}",
response=e.response,
request=e.request,
) from e
raise
data = r.json()
msg = (data.get("choices") or [{}])[0].get("message") or {}
return normalize_message_content(msg.get("content"))

View File

@ -0,0 +1,22 @@
"""(连接, 读) 超时时长:供 ``chat/completions`` 与视觉请求使用。"""
from __future__ import annotations
import os
def chat_completion_read_timeout() -> tuple[float, float]:
read = 600
raw = (os.environ.get("LLM_CHAT_TIMEOUT") or os.environ.get("OPENAI_TIMEOUT") or "").strip()
if raw:
try:
read = max(60, int(raw))
except ValueError:
pass
conn = 30.0
raw_c = (os.environ.get("LLM_CHAT_CONNECT_TIMEOUT") or "").strip()
if raw_c:
try:
conn = max(5.0, float(raw_c))
except ValueError:
pass
return (conn, float(read))