hub-gif 9fd32a3f84 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
2026-04-27 10:24:50 +08:00

536 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""配料识别与详情长图逆序等逻辑。由 `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