Initial commit of Tmall new-product analysis loop v9.2.5.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
喻长发
2026-09-03 15:42:05 +08:00
co-authored by Cursor
commit 42223969db
55 changed files with 9760 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
import json, os, re, ssl, time, urllib.request, urllib.error
from pathlib import Path
ROOT = Path(__file__).resolve().parent
SERVER_VERSION = '9.2.5'
def load_config():
cfg = {}
p = Path(os.getenv('AI_CONFIG_PATH') or ROOT/'config.json').expanduser()
cfg['_config_path'] = str(p)
cfg['_config_error'] = ''
if p.exists():
try:
raw = p.read_text('utf-8').strip()
data = json.loads(raw)
if not isinstance(data, dict):
raise ValueError('config.json 顶层必须是 JSON 对象')
cfg.update(data)
except json.JSONDecodeError as e:
cfg['_config_error'] = f'config.json JSON 格式错误:第 {e.lineno} 行第 {e.colno} 列:{e.msg}'
except Exception as e:
cfg['_config_error'] = f'{type(e).__name__}: {e}'
else:
cfg['_config_error'] = 'config.json 不存在'
cfg['api_base'] = os.getenv('AI_API_BASE', cfg.get('api_base','https://api.bananarouter.com/v1')).rstrip('/')
cfg['api_key'] = os.getenv('AI_API_KEY', cfg.get('api_key',''))
cfg['model'] = os.getenv('AI_MODEL', cfg.get('model',''))
cfg['chat_path'] = str(cfg.get('chat_path','/chat/completions'))
cfg['models_path'] = str(cfg.get('models_path','/models'))
try: cfg['temperature'] = float(os.getenv('AI_TEMPERATURE', cfg.get('temperature',0.2)))
except Exception: cfg['temperature'] = 0.2
try: cfg['timeout'] = int(os.getenv('AI_TIMEOUT', cfg.get('timeout',120)))
except Exception: cfg['timeout'] = 120
try: cfg['retries'] = max(0, int(cfg.get('retries',2)))
except Exception: cfg['retries'] = 2
cfg['extra_headers'] = cfg.get('extra_headers',{}) or {}
cfg['ca_bundle'] = str(cfg.get('ca_bundle','') or '')
cfg['ssl_verify'] = bool(cfg.get('ssl_verify', True))
return cfg
def configured():
c=load_config()
key=str(c.get('api_key') or '').strip(); model=str(c.get('model') or '').strip()
return bool(not c.get('_config_error') and key and model and not key.startswith('YOUR_') and not model.startswith('YOUR_'))
def _ssl_context(c):
if not c.get('ssl_verify', True):
raise RuntimeError('ssl_verify=false 被禁止。V6.2 不允许关闭 HTTPS 证书校验。')
cafile = c.get('ca_bundle') or ''
bundled = ROOT/'cacert.pem'
if not cafile and bundled.exists(): cafile=str(bundled)
if cafile:
if not Path(cafile).exists():
raise RuntimeError(f'ca_bundle 文件不存在:{cafile}')
ctx=ssl.create_default_context(cafile=cafile)
else:
try:
import certifi
ctx=ssl.create_default_context(cafile=certifi.where())
except Exception:
ctx=ssl.create_default_context()
try: ctx.minimum_version=ssl.TLSVersion.TLSv1_2
except Exception: pass
return ctx
def _headers(c):
h={'Content-Type':'application/json','Accept':'application/json','Authorization':'Bearer '+c['api_key'],'User-Agent':'TmallAIProductAnalysis/9.2.5'}
h.update(c.get('extra_headers',{}))
return h
def _join(base,path):
path='/' + str(path or '').lstrip('/')
return base.rstrip('/') + path
def probe_model_api(timeout=15):
c=load_config()
if c.get('_config_error'):
return {'ok':False,'stage':'config','error':c['_config_error']}
if not c.get('api_key') or not c.get('model'):
return {'ok':False,'stage':'config','error':'api_key 或 model 为空'}
try:
req=urllib.request.Request(_join(c['api_base'],c['models_path']),headers=_headers(c),method='GET')
with urllib.request.urlopen(req,timeout=min(timeout,c['timeout']),context=_ssl_context(c)) as r:
obj=json.loads(r.read().decode('utf-8','ignore') or '{}')
ids=[x.get('id') for x in (obj.get('data') or []) if isinstance(x,dict)]
return {'ok':True,'stage':'models','http':200,'modelFound':(c['model'] in ids) if ids else None,'model':c['model'],'modelsCount':len(ids)}
except urllib.error.HTTPError as e:
body=e.read().decode('utf-8','ignore')
return {'ok':False,'stage':'models','http':e.code,'error':body[:500] or str(e)}
except ssl.SSLCertVerificationError as e:
return {'ok':False,'stage':'ssl','error':f'SSL证书验证失败:{e}. V6.2 已优先使用 certifi;如仍失败,请确认 Python/代理没有替换证书链。'}
except Exception as e:
return {'ok':False,'stage':'network','error':str(e)}
def _parse_json(s):
if isinstance(s, dict): return s
s = (s or '').strip()
s = re.sub(r'^```(?:json)?\s*|\s*```$', '', s, flags=re.I|re.S)
try: return json.loads(s)
except Exception:
a=s.find('{'); b=s.rfind('}')
if a>=0 and b>a: return json.loads(s[a:b+1])
raise
def _do_post(c,payload):
req=urllib.request.Request(_join(c['api_base'],c['chat_path']),data=json.dumps(payload,ensure_ascii=False).encode('utf-8'),headers=_headers(c),method='POST')
with urllib.request.urlopen(req,timeout=c['timeout'],context=_ssl_context(c)) as r:
return json.loads(r.read().decode('utf-8','ignore'))
def chat_json(system, user, images=None, max_tokens=5000):
c=load_config()
if c.get('_config_error'):
raise RuntimeError(c['_config_error'])
if not c.get('api_key') or not c.get('model'):
raise RuntimeError('未配置模型。请复制 config.example.json 为 config.json,并填写 api_key 与 model。')
content=[{'type':'text','text':user}]
for url in (images or [])[:12]:
if isinstance(url,str) and url.startswith(('http://','https://','data:image/')):
content.append({'type':'image_url','image_url':{'url':url}})
base_payload={
'model':c['model'],
'messages':[{'role':'system','content':system},{'role':'user','content':content if len(content)>1 else user}],
'temperature':c['temperature'],
'response_format':{'type':'json_object'},
'max_tokens':max_tokens
}
variants=[base_payload]
# Some OpenAI-compatible gateways/models reject response_format, temperature, or max_tokens.
v2=dict(base_payload); v2.pop('response_format',None); variants.append(v2)
v3=dict(v2); v3.pop('temperature',None); v3['max_completion_tokens']=v3.pop('max_tokens',max_tokens); variants.append(v3)
last=None
for attempt in range(c['retries']+1):
for payload in variants:
try:
obj=_do_post(c,payload)
try: return _parse_json(obj['choices'][0]['message']['content'])
except Exception as e: raise RuntimeError(f'模型返回无法解析为 JSON: {e}')
except urllib.error.HTTPError as e:
body=e.read().decode('utf-8','ignore')
last=RuntimeError(f'模型接口 HTTP {e.code}: {body[:900]}')
if e.code not in (400,408,409,429,500,502,503,504): raise last
except ssl.SSLCertVerificationError as e:
raise RuntimeError(f'SSL证书验证失败:{e}。V6.2 已使用 certifi CA;请检查本机 Python/代理证书链。')
except Exception as e:
last=RuntimeError(f'模型接口调用失败: {e}')
if attempt<c['retries']:
time.sleep(min(1.5*(attempt+1),4))
raise last or RuntimeError('模型接口调用失败')
+1362
View File
File diff suppressed because it is too large Load Diff
+3892
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"api_base": "https://api.bananarouter.com/v1",
"api_key": "",
"model": "gpt-5.5",
"chat_path": "/chat/completions",
"models_path": "/models",
"temperature": 0.2,
"timeout": 120,
"retries": 2,
"ssl_verify": true,
"ca_bundle": "",
"extra_headers": {}
}
+60
View File
@@ -0,0 +1,60 @@
"""可复用的提示词模板注册表。
后端只负责准备 evidence/context,模板负责描述任务;接入其它项目时可直接
替换模板或覆盖变量,不需要在业务代码里拼接提示词。
"""
from dataclasses import dataclass
import json
from pathlib import Path
PRODUCT_VARIABLES = {
"product": "商品基础信息:标题、商品ID、类目、品牌、价格、销量等页面字段",
"attributes": "商品参数/规格:仅包含页面实际采集到的名称和值",
"images": "图片证据:主图、详情图、SKU图、评论原图及其 evidenceId",
"reviews": "消费者评论:原文、评分、SKU、日期和评论 evidenceId",
"questions": "问大家/购买问答:问题、回答及 QA evidenceId",
"programStats": "程序统计:评论数量、图片数量、参数数量等可计算指标",
"evidence": "统一证据账本;模型只能引用其中的 evidenceId",
}
@dataclass(frozen=True)
class PromptTemplate:
name: str
body: str
variables: tuple = ()
def render(self, **values):
missing = [v for v in self.variables if v not in values]
if missing:
raise ValueError(f"模板 {self.name} 缺少变量: {', '.join(missing)}")
return self.body.format(**values)
TEMPLATES = {
"system": PromptTemplate("system", "{system}\n\n可用商品变量说明:\n{variable_schema}", ("system", "variable_schema")),
"task": PromptTemplate("task", "{task}\n\n输入变量:{input_variables}", ("task", "input_variables")),
}
def register_template(name, body, variables=()):
"""覆盖或新增模板,供宿主项目在启动时注入行业提示词。"""
TEMPLATES[name] = PromptTemplate(name, body, tuple(variables))
def render_template(name, **values):
if name not in TEMPLATES:
raise KeyError(f"未知提示词模板: {name}")
return TEMPLATES[name].render(**values)
def variable_schema():
return json.dumps(PRODUCT_VARIABLES, ensure_ascii=False, indent=2)
def build_system_prompt(system_text):
override=Path(__file__).with_name('prompts.json')
if override.exists():
try:
data=json.loads(override.read_text('utf-8'))
if data.get('system',{}).get('body'): return data['system']['body'].format(system=system_text, variable_schema=variable_schema())
except Exception: pass
return TEMPLATES["system"].render(system=system_text, variable_schema=variable_schema())
def build_task_prompt(task_text, variables=("evidence",)):
names = "、".join(f"{name}({PRODUCT_VARIABLES.get(name, '任务上下文变量')})" for name in variables)
return TEMPLATES["task"].render(task=task_text, input_variables=names)
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
import argparse
import json
from pathlib import Path
from analysis import ensure_experience_solution
from renderer import render
def main():
parser = argparse.ArgumentParser(description='从已有分析 JSON 重建完整事实报告,不调用模型 API。')
parser.add_argument('input', type=Path)
parser.add_argument('--output', type=Path)
args = parser.parse_args()
source = args.input.resolve()
analysis = json.loads(source.read_text('utf-8'))
result = ensure_experience_solution(analysis)
output = (args.output or source.with_name(source.stem + '_fixed.html')).resolve()
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(render(result, source.stem), 'utf-8')
output.with_suffix('.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), 'utf-8')
print(output)
if __name__ == '__main__':
main()
+686
View File
@@ -0,0 +1,686 @@
import html
import re
from analysis import ensure_experience_solution
UNCERTAIN = ('可能', '或许', '推测', '无法判断', '不能判断', '证据不足', '尚不能', '不足以', '未知', '未识别', '不代表', '待验证')
def H(v): return html.escape('' if v is None else str(v))
def arr(v): return v if isinstance(v, list) else []
def safe(v): return '' if v in (None, '', [], {}) else (str(v) if not isinstance(v,(dict,list,tuple)) else '')
def text_of(v, keys=('text','point','statement','label','value','driver','problem','concern','signal','item')):
if v in (None,'',[],{}): return ''
if isinstance(v,str): return v
if isinstance(v,(int,float)): return str(v)
if isinstance(v,dict):
for k in keys:
if v.get(k) not in (None,'',[],{}): return text_of(v.get(k),keys)
return ''
return ''
def concise(v, limit=90, verified=True):
text = re.sub(r'\s+', ' ', safe(v)).strip()
if not text: return ''
parts = [x.strip() for x in re.split(r'(?<=[。!?;])', text) if x.strip()]
if verified:
text = ''.join(x for x in parts if not any(word in x for word in UNCERTAIN))
if not text: return ''
if len(text) <= limit: return text
cut = max(text.rfind(mark, 0, limit + 1) for mark in ',。;:')
if cut < limit // 2: cut = limit
return text[:cut].rstrip(',。;: ') + '…'
def evidence_index(a): return {x.get('id'): x for x in arr(a.get('evidenceLedger')) if isinstance(x, dict) and x.get('id')}
def image_entry(eid, idx, allowed_groups=None):
e = idx.get(eid) or {}; value = e.get('value')
group=(e.get('meta') or {}).get('group')
if e.get('type')!='image' or not isinstance(value,str) or not value.startswith(('http://','https://','data:image/')):return None
if allowed_groups and group not in allowed_groups:return None
return e
def image_url(eid, idx, allowed_groups=None):
e=image_entry(eid,idx,allowed_groups)
if not e:return ''
return (e.get('meta') or {}).get('localUrl') or e.get('value')
def image_source(e):
if not isinstance(e,dict):return ('','')
meta=e.get('meta') or {}; return (meta.get('localUrl') or e.get('value') or '', meta.get('sourceUrl') or e.get('value') or '')
def img_tag(url, alt='商品图片', fallback=''):
if not url:return ''
fb=fallback if fallback and fallback!=url else ''
attrs=f' data-fallback="{H(fb)}"' if fb else ''
return f'<img loading="eager" decoding="async" src="{H(url)}"{attrs} alt="{H(alt)}" onerror="if(this.dataset.fallback){{this.src=this.dataset.fallback;this.dataset.fallback=\'\';return;}}this.classList.add(\'image-error\');">'
def product_hero(visual, idx):
for item in arr(visual.get('items')):
if isinstance(item, dict):
url = image_url(safe(item.get('imageEvidenceId')), idx, {'main'})
if url: return url
return ''
def action_list(text, limit=5):
items = []
for value in re.split(r'[;。]\s*', safe(text)):
value = concise(value, 64)
if value and value not in items: items.append(value)
return '<ul class="action-list">' + ''.join(f'<li>{H(x)}</li>' for x in items[:limit]) + '</ul>' if items else ''
def owner_cards(cards):
out = []
for x in arr(cards)[:6]:
if not isinstance(x, dict): continue
headline = concise(x.get('headline'), 46)
bullets = [concise(v, 42) for v in arr(x.get('bullets'))]
bullets = [v for v in bullets if v][:2]
if not headline: continue
detail = '<ul>' + ''.join(f'<li>{H(v)}</li>' for v in bullets) + '</ul>' if bullets else ''
out.append(f'<article class="owner-card {H(safe(x.get("tone")) or "normal")}"><span>{H(x.get("label"))}</span><strong>{H(headline)}</strong>{detail}</article>')
return ''.join(out)
def overview_rows(items, kind):
out = []
for x in arr(items)[:6]:
if not isinstance(x, dict): continue
label = concise(x.get('label'), 24, False)
value = concise(x.get('value'), 84, False)
note = concise(x.get('note'), 42, False)
priority = concise(x.get('priority'), 14, False)
if not label or not value: continue
meta = priority or note
out.append(
f'<article class="shopper-row {H(kind)}">'
+ (f'<span class="shopper-meta">{H(meta)}</span>' if meta else '')
+ f'<strong>{H(label)}</strong><p>{H(value)}</p></article>'
)
return ''.join(out)
def platform_overview_html(overview):
facts = overview_rows(overview.get('facts'), 'fact')
outcomes = overview_rows(arr(overview.get('outcomes'))[:3], 'reason')
actions = overview_rows(overview.get('actions'), 'check')
groups = ''.join([
f'<section class="shopper-group"><h3>交易与产品事实</h3><div class="shopper-grid">{facts}</div></section>' if facts else '',
f'<section class="shopper-group"><h3>购后体验验证</h3><div class="shopper-grid">{outcomes}</div></section>' if outcomes else '',
f'<section class="shopper-group full"><h3>平台经营建议</h3><div class="shopper-grid">{actions}</div></section>' if actions else '',
])
verdict = concise(overview.get('verdict'), 70, False)
sample = concise(overview.get('sampleNote'), 64, False)
return '<div class="shopper-decision">' + (
f'<header class="shopper-verdict"><span>产品分析结论</span><strong>{H(verdict)}</strong>'
+ (f'<small>{H(sample)}</small>' if sample else '') + '</header>' if verdict else ''
) + f'<div class="shopper-groups">{groups}</div></div>'
def decision_boundary_html(base):
reviews = base.get('reviewCount', 0)
public = safe(base.get('publicReviewCount'))
coverage = f'已采有效评论 {reviews}' + (f' / 页面公开 {public}' if public else '')
status = '评论采集已确认完整' if base.get('reviewCollectionComplete') else '评论采集未确认完整'
items = [
('证据覆盖', coverage, status),
('本报告支持', '商品拆解、样品验证、规格校准、页面信息修正', '基于当前页面参数与已采评论'),
('适用范围', '不输出生产规模、利润、转化或市场容量', '当前数据不含对应经营指标'),
('补充数据', '完整评论、流量转化、退款售后、成本毛利', '补齐后再进入经营测算'),
]
return '<section class="decision-boundary">' + ''.join(
f'<article><span>{H(label)}</span><strong>{H(value)}</strong><small>{H(note)}</small></article>'
for label, value, note in items
) + '</section>'
def review_results_html(overview):
def rows(items, tone):
out=[]
for x in arr(items)[:6]:
if not isinstance(x,dict):continue
label=concise(x.get('label'),24,False);value=concise(x.get('value'),100,False);note=concise(x.get('note') or x.get('priority'),26,False)
if not label or not value:continue
out.append(f'<article class="result-row {H(tone)}">'+(f'<span>{H(note)}</span>' if note else '')+f'<strong>{H(label)}</strong><p>{H(value)}</p></article>')
return ''.join(out)
outcomes=rows(overview.get('outcomes'),'positive')
risks=rows([x for x in arr(overview.get('checks')) if isinstance(x,dict) and x.get('priority')=='明确反馈'],'risk')
questions=rows(overview.get('questions'),'question')
return ''.join([
f'<section class="result-block"><h3>评论确认的结果</h3><div class="result-grid">{outcomes}</div></section>' if outcomes else '',
f'<section class="result-block"><h3>明确问题</h3><div class="result-grid">{risks}</div></section>' if risks else '',
f'<section class="result-block"><h3>购买问答反馈</h3><div class="result-grid">{questions}</div></section>' if questions else '',
])
def visual_items(items, idx):
out = []
for x in arr(items):
if not isinstance(x, dict): continue
eid=safe(x.get('imageEvidenceId'));entry=image_entry(eid,idx,{'main','detail'})
if not entry: continue
url=entry.get('value');group=(entry.get('meta') or {}).get('group');source=(entry.get('meta') or {}).get('source')
source_label='商品主图画廊' if group=='main' else '商品详情描述区域'
if source not in ('meta_og_image','dom_main_gallery','structured_main_gallery','dom_product_description','structured_product_description'):continue
headline = concise(x.get('headline'), 42)
frame = concise(x.get('frameInfo'), 90)
subject = concise(x.get('productSubject'), 90)
pattern = concise(x.get('patternColor'), 90)
text_info = concise(x.get('textInfo'), 90)
hierarchy = concise(x.get('informationHierarchy'), 90)
task = concise(x.get('contentTask') or x.get('persuasionTask'), 72)
weakness = concise(x.get('weakness'), 72)
action = concise(x.get('borrowableAction') or x.get('action') or x.get('nextProductAction'), 82)
facts = ''.join([
f'<div><span>画面内容</span><b>{H(frame)}</b></div>' if frame else '',
f'<div><span>商品主体</span><b>{H(subject)}</b></div>' if subject else '',
f'<div><span>花型配色</span><b>{H(pattern)}</b></div>' if pattern else '',
f'<div><span>文案信息</span><b>{H(text_info)}</b></div>' if text_info else '',
f'<div><span>层级关系</span><b>{H(hierarchy)}</b></div>' if hierarchy else '',
f'<div><span>表达任务</span><b>{H(task)}</b></div>' if task else '',
f'<div><span>明确问题</span><b>{H(weakness)}</b></div>' if weakness else '',
f'<div class="action"><span>可借鉴动作</span><b>{H(action)}</b></div>' if action else ''
])
out.append('<article class="story-item"><figure class="story-media">' + img_tag(url, '商品主图' if group=='main' else '商品详情图') + '</figure><div class="story-copy"><span class="source-badge">来源:' + H(source_label) + '</span><h3>' + H(headline or ('已核验主图' if group=='main' else '已核验详情图')) + '</h3><div class="evidence">' + facts + '</div></div></article>')
return ''.join(out)
def buyer_evidence_html(block, idx):
groups={}
for item in arr((block or {}).get('items'))[:24]:
if not isinstance(item,dict):continue
image_id=safe(item.get('imageEvidenceId'));review_id=safe(item.get('reviewEvidenceId'))
image=image_entry(image_id,idx,{'buyerShow'});review=idx.get(review_id) or {};meta=(image or {}).get('meta') or {}
if not image or review.get('type')!='review' or review_id not in arr(meta.get('reviewEvidenceIds')):continue
value=review.get('value') if isinstance(review.get('value'),dict) else {};quote=concise(value.get('content'),90,False)
if not quote:continue
details=' · '.join(x for x in (concise(value.get('sku'),42,False),concise(value.get('date'),18,False)) if x)
group=groups.setdefault(review_id,{'quote':quote,'details':details,'images':[]})
group['images'].append(image.get('value'))
if not groups:return ''
title=H((block or {}).get('title') or '评论原图与对应评价')
image_count=sum(len(x['images']) for x in groups.values())
out=[]
for review_id,group in groups.items():
gallery=''.join(
'<figure>'+img_tag(url,f'{review_id} 评论原图 {i}')+f'<figcaption>原图 {i:02d}</figcaption></figure>'
for i,url in enumerate(group['images'],1)
)
out.append('<article class="buyer-review-group"><div class="buyer-review-copy"><span>评论证据 · '+H(review_id)+'</span><blockquote>'+H(group['quote'])+'</blockquote>'+('<small>'+H(group['details'])+'</small>' if group['details'] else '')+'</div><div class="buyer-gallery">'+gallery+'</div></article>')
return f'<section class="buyer-evidence"><header><span>评论图片证据</span><h3>{title} · {len(groups)} 条评价 / {image_count} 张原图</h3><p>每组图片只关联同一条评论,按原比例完整展示;不作为商品主图、详情图或 SKU 图使用。</p></header><div class="buyer-groups">'+''.join(out)+'</div></section>'
def stats_html(stats):
out = []
for x in arr(stats)[:6]:
if isinstance(x, dict) and safe(x.get('value')):
out.append(f'<div class="voice-stat"><span>{H(concise(x.get("label"), 16, False))}</span><strong>{H(concise(x.get("value"), 18, False))}</strong></div>')
return ''.join(out)
def journey_html(stages):
xs = [x for x in arr(stages) if isinstance(x, dict)]
tabs, panels = [], []
for i, x in enumerate(xs):
positive = concise(x.get('positiveConfirmation'), 70); friction = concise(x.get('friction'), 70)
if not positive and not friction: continue
active = ' active' if not tabs else ''
tabs.append(f'<button class="jtab{active}" data-i="{len(tabs)}"><small>{H(x.get("stageNo") or str(i + 1).zfill(2))}</small><b>{H(x.get("label"))}</b></button>')
facts = (f'<section><span>已确认</span><p>{H(positive)}</p></section>' if positive else '') + (f'<section><span>明确问题</span><p>{H(friction)}</p></section>' if friction else '')
quotes = []
for q in arr(x.get('quotes'))[:2]:
if isinstance(q, dict) and safe(q.get('text')): quotes.append(f'<li><span>{H(q.get("type"))}</span>{H(concise(q.get("text"), 90, False))}</li>')
panels.append(f'<div class="jpanel{active}"><header><span>第 {H(x.get("stageNo") or i + 1)} 阶段</span><h4>{H(x.get("label"))}</h4><p>{H(concise(x.get("headline"), 54))}</p></header><div class="jgrid">{facts}</div>' + ('<ul class="quotes">' + ''.join(quotes) + '</ul>' if quotes else '') + '</div>')
return '<div class="journey-tabs">' + ''.join(tabs) + '</div>' + ''.join(panels) if tabs else ''
def signals_html(signals):
if not signals: return ''
values = []
for key, tone in [('positive', 'good'), ('questions', 'ask'), ('risks', 'risk')]:
for x in arr(signals.get(key))[:5]:
label = concise(x.get('label'), 42) if isinstance(x, dict) else ''
if label: values.append(f'<span class="signal {tone}">{H(label)}</span>')
return '<section class="signal-atlas"><span class="label">已确认体验信号</span><div class="signals">' + ''.join(values) + '</div></section>' if values else ''
def param_html(xs):
out = []
for x in arr(xs)[:8]:
value = concise(x.get('value'), 82) if isinstance(x, dict) else ''
if value: out.append(f'<div class="param"><span>{H(x.get("label"))}</span><b>{H(value)}</b></div>')
return ''.join(out)
def gates_html(xs):
out = []
for x in arr(xs)[:6]:
action = concise(x.get('action'), 92) if isinstance(x, dict) else ''
if action: out.append(f'<div class="gate"><b>{H(x.get("priority"))} · {H(concise(x.get("label"), 24, False))}</b><span>{H(action)}</span></div>')
return ''.join(out)
def plans_html(xs):
out = []
for x in arr(xs)[:3]:
if not isinstance(x, dict): continue
selling = selling_points_html(x.get('sellingPoints'))
specs = []
for spec in arr(x.get('specs')):
value = concise(spec.get('value'), 82) if isinstance(spec, dict) else ''
if value: specs.append(f'<div><span>{H(spec.get("label"))}</span><b>{H(value)}</b></div>')
mg = x.get('manufacturingGate') or {}; vc = x.get('visualCommerce') or {}
out.append('<article class="plan"><span>' + H(x.get('type')) + '</span><h3>' + H(concise(x.get('name'), 30, False)) + '</h3><p>' + H(concise(x.get('positioning'), 110)) + '</p>' + selling + '<div class="plan-specs">' + ''.join(specs) + '</div><div class="plan-actions"><section><h4>' + H(mg.get('title') or '制造闸门') + '</h4>' + action_list(mg.get('content')) + '</section><section><h4>' + H(vc.get('title') or '首图与商详') + '</h4>' + action_list(vc.get('content')) + '</section></div></article>')
return ''.join(out)
def selling_points_html(xs):
out=[]
for x in arr(xs)[:5]:
if not isinstance(x,dict):continue
slogan=concise(x.get('slogan'),34,False)
value=concise(x.get('consumerValue'),54,False)
source=concise(x.get('evidenceSource'),32,False)
action=concise(x.get('productAction'),64,False)
if not slogan:continue
out.append(f'<article><strong>{H(slogan)}</strong>'+(f'<p>{H(value)}</p>' if value else '')+(f'<small>{H(source)}</small>' if source else '')+(f'<b>{H(action)}</b>' if action else '')+'</article>')
return '<section class="selling-points"><header><span>NEXT PRODUCT SELLING POINTS</span><h4>下一款明确卖点</h4></header><div>'+''.join(out)+'</div></section>' if out else ''
def governance_html(gov):
if not isinstance(gov,dict):return ''
items=[('报告角色',gov.get('role')),('结构标准',gov.get('structure')),('内容来源',gov.get('sourceRule')),('证据规则',gov.get('evidenceRule'))]
rows=''.join(f'<article><span>{H(label)}</span><b>{H(concise(value,64,False))}</b></article>' for label,value in items if safe(value))
return '<section class="governance"><div class="wrap"><div class="governance-grid">'+rows+'</div></div></section>' if rows else ''
def attr_lookup(raw, names):
for x in arr(raw.get('attributes')):
if not isinstance(x,dict):continue
name=safe(x.get('name'))
if any(k in name for k in names) and safe(x.get('value')):return safe(x.get('value'))
return ''
def product_profile_html(raw, sales, base):
rows=[
('一级品类', safe((raw.get('product') or {}).get('category')) or '床上用品'),
('商品标题', safe((raw.get('product') or {}).get('title'))),
('价格定位', ('¥'+safe(sales.get('currentPrice'))) if safe(sales.get('currentPrice')) else ''),
('公开销量', safe(sales.get('sold')) or safe(sales.get('cumulativeSales'))),
('目标人群', attr_lookup(raw, ['适用人群','适用对象'])),
('核心场景', attr_lookup(raw, ['适用场景','适用床尺寸','床品尺寸'])),
('产品结构', attr_lookup(raw, ['件数','套件组成','组合形式','款式'])),
('材质成分', attr_lookup(raw, ['成分含量','床单面料材质','被面材质','材质','棉种类'])),
('支数密度', ' / '.join(x for x in [attr_lookup(raw,['面料支数','纱线支数']), attr_lookup(raw,['面料密度','织物密度'])] if x)),
('工艺结构', attr_lookup(raw, ['床品工艺','织造工艺','印染工艺','工艺'])),
('风格花型', ' / '.join(x for x in [attr_lookup(raw,['风格']), attr_lookup(raw,['主图案类型','颜色分类'])] if x)),
('评论样本', f'{base.get("reviewCount",0)} 条有效评论'),
]
cells=''.join(f'<div class="profile-cell"><span>{H(k)}</span><b>{H(concise(v,78,False))}</b></div>' for k,v in rows if safe(v))
return '<div class="profile-board">'+cells+'</div>' if cells else ''
def deal_logic_html(plans):
points=[]
for plan in arr(plans.get('plans'))[:1]:
if isinstance(plan,dict):points=arr(plan.get('sellingPoints'))
rows=[]
for i,x in enumerate(points[:5],1):
if not isinstance(x,dict):continue
rows.append(f'<article class="logic-step"><span>{i:02d}</span><strong>{H(concise(x.get("slogan"),34,False))}</strong><p>{H(concise(x.get("consumerValue"),62,False))}</p><small>{H(concise(x.get("evidenceSource"),36,False))}</small></article>')
return '<div class="logic-chain">'+''.join(rows)+'</div>' if rows else ''
def feature_power_html(overview, product_exp):
cards=[]
for x in arr(overview.get('cards')):
if not isinstance(x,dict):continue
label=safe(x.get('label')); headline=concise(x.get('headline'),42,False)
if not headline or label in ('销售事实','页面活动'):continue
tone=safe(x.get('tone')) or 'normal'
cards.append(f'<article class="feature-card {H(tone)}"><span>{H(label)}</span><strong>{H(headline)}</strong></article>')
for x in arr(product_exp.get('gates'))[:3]:
if not isinstance(x,dict):continue
cards.append(f'<article class="feature-card risk"><span>{H(x.get("priority"))}</span><strong>{H(concise(x.get("label"),34,False))}</strong><p>{H(concise(x.get("action"),64,False))}</p></article>')
return '<div class="feature-grid">'+''.join(cards[:8])+'</div>' if cards else ''
def marketing_html(raw):
product=raw.get('product') or {}; title=safe(product.get('title'))
slots=[
('品类词', '床品 / 三件套' if re.search(r'床品|三件套|四件套|被套|床单|床笠', title) else ''),
('人群词', attr_lookup(raw,['适用人群']) or ('学生' if '学生' in title else '')),
('场景词', attr_lookup(raw,['适用床尺寸','适用场景'])),
('材质词', attr_lookup(raw,['床单面料材质','被面材质','成分含量','材质'])),
('工艺词', attr_lookup(raw,['面料支数','面料密度','床品工艺','织造工艺'])),
('风格词', attr_lookup(raw,['风格','主图案类型'])),
]
slot_html=''.join(f'<div class="market-slot"><span>{H(k)}</span><b>{H(concise(v,62,False))}</b></div>' for k,v in slots if safe(v))
promos=''.join(f'<li>{H(concise(x.get("text"),58,False))}</li>' for x in arr(raw.get('promotions'))[:4] if isinstance(x,dict) and safe(x.get('text')))
return '<div class="marketing-grid"><article><span>商品标题</span><strong>'+H(concise(title,90,False))+'</strong><div class="market-slots">'+slot_html+'</div></article><article><span>页面营销表达</span><ul>'+promos+'</ul></article></div>'
def visual_status_html(images, verified_visual):
rows=[('主图',len(arr(images.get('main'))),bool(arr(images.get('main')))),('详情图',len(arr(images.get('detail'))),bool(arr(images.get('detail')))),('SKU 图',len(arr(images.get('sku'))),bool(arr(images.get('sku')))),('评论实拍',len(arr(images.get('buyerShow'))),bool(arr(images.get('buyerShow'))))]
cards=''.join(f'<article class="visual-state {"ready" if ok else "missing"}"><span>{H(k)}</span><strong>{n}</strong><small>{"已采集" if ok else "未采集"}</small></article>' for k,n,ok in rows)
body=verified_visual or '<p class="evidence-note">主图/详情图未形成可核验图片条目时,本章只展示来源状态,不用评论图片替代主图或详情图。</p>'
return '<div class="visual-states">'+cards+'</div><div class="story">'+body+'</div>'
def competition_html(plans, product_exp, overview):
point=''
for plan in arr(plans.get('plans'))[:1]:
if isinstance(plan,dict) and arr(plan.get('sellingPoints')):
point=concise(plan['sellingPoints'][0].get('slogan'),42,False)
risks=[concise(x.get('label'),34,False) for x in arr(product_exp.get('gates'))[:3] if isinstance(x,dict)]
return '<div class="competition-grid"><article class="strong"><span>核心价值</span><strong>'+H(point or '已确认体验可复用')+'</strong><p>来自页面参数与评论聚合,不写市场推断。</p></article><article class="mid"><span>基础能力</span><strong>类目关键参数、规格与页面需要和真实商品一致</strong><p>基础能力必须被主图、详情和参数共同证明。</p></article><article class="weak"><span>风险点</span><strong>'+H(' / '.join(x for x in risks if x) or '按评论明确问题处理')+'</strong><p>风险只按真实评论和参数校验项呈现。</p></article></div>'
def validation_rows(xs):
out = []
for x in arr(xs)[:6]:
if isinstance(x, dict) and any(safe(x.get(k)) for k in ('stage','metrics','successMeaning','nextAction')):
stage=concise(x.get('stage'),18,False);owner=concise(x.get('owner'),24,False)
if not owner:continue
out.append(f'<tr><td>{H(stage)}</td><td>{H(owner)}</td><td>{H(concise(x.get("metrics"), 62))}</td><td>{H(concise(x.get("successMeaning"), 58))}</td><td>{H(concise(x.get("nextAction"), 62))}</td></tr>')
return ''.join(out)
# V9.2 图片驱动专业报告。分析可以深,默认报告必须轻。
DISPLAY_MARKERS = ('[数据事实]','[分析判断]','[设计建议]','[证据不足]','数据事实:','分析判断:','设计建议:')
def clean_display(v, limit=120):
t=text_of(v) if not isinstance(v,str) else v
if not t:return ''
t=re.sub(r'\*\*|__','',t)
for m in DISPLAY_MARKERS:t=t.replace(m,'')
t=re.sub(r'\s*/\s*',';',t)
t=re.sub(r'\s+',' ',t).strip(' ;/')
return concise(t,limit,False)
def first_text(*values, limit=100):
for v in values:
t=clean_display(v,limit)
if t:return t
return ''
def _entries(idx, group, limit=200):
return [(eid,e) for eid,e in idx.items() if e.get('type')=='image' and (e.get('meta') or {}).get('group')==group][:limit]
def _visual_figure(e, alt, css=''):
src,fb=image_source(e)
if not src:return '<figure class="visual '+H(css)+' failed"><div class="image-fallback">图片未采集</div></figure>'
return '<figure class="visual '+H(css)+'">'+img_tag(src,alt,fb)+'<div class="image-fallback">图片暂不可用</div></figure>'
def _section(no,title,deck,body,sid):
return f'<section class="v92-section" id="{H(sid)}"><div class="wrap"><header class="v92-head"><span>{H(no)}</span><h2>{H(title)}</h2><p>{H(deck)}</p></header>{body}</div></section>'
def _opinion(text):
text=clean_display(text,45)
return f'<div class="v92-opinion v925-opinion"><strong>{H(text)}</strong></div>' if text else ''
def _chips(values, limit=3):
vals=[]
for v in arr(values)[:limit]:
t=clean_display(v.get('value') if isinstance(v,dict) else v,10)
if t and t not in vals: vals.append(t)
return '<div class="v925-signals">'+H(' · '.join(vals))+'</div>' if vals else ''
def _owner_overview(exp, raw, base, sales):
src=arr((exp.get('ownerOverview') or {}).get('cards'))
wanted=['为什么能卖','值得继承','下一款优先']
mapped={k:None for k in wanted}
for x in src:
if not isinstance(x,dict): continue
label=clean_display(x.get('label'),16); headline=clean_display(x.get('headline'),20); note=clean_display(x.get('note'),22)
if not headline: continue
for k in wanted:
if k in label and mapped[k] is None: mapped[k]=(headline,note)
# Compatibility fallback from old labels.
legacy={'为什么能卖':['商品定位','场景价值'],'值得继承':['主要产品资产','产品价值'],'下一款优先':['下一款优先方向','可强化方向']}
for x in src:
if not isinstance(x,dict): continue
label=clean_display(x.get('label'),18); headline=clean_display(x.get('headline'),20)
for target,keys in legacy.items():
if mapped[target] is None and any(k in label for k in keys) and headline: mapped[target]=(headline,'')
pd=arr((exp.get('productValue') or {}).get('items'))
for x in pd:
if not isinstance(x,dict): continue
typ=clean_display(x.get('type'),16); val=first_text(x.get('value'),limit=30)
if typ=='场景价值' and mapped['为什么能卖'] is None: mapped['为什么能卖']=(val,'')
elif typ=='产品价值' and mapped['值得继承'] is None: mapped['值得继承']=(val,'')
elif typ=='可强化方向' and mapped['下一款优先'] is None: mapped['下一款优先']=(val,'')
title=clean_display((raw.get('product') or {}).get('title'),36)
mapped['为什么能卖']=mapped['为什么能卖'] or (title or '当前商品场景与产品组合', '')
mapped['值得继承']=mapped['值得继承'] or (f'已确认 {base.get("attributeCount",0)} 项产品参数', '')
mapped['下一款优先']=mapped['下一款优先'] or ('规格更清楚 · 品质更可证 · 图片更好选', '')
cards=[]
for k in wanted:
headline,note=mapped[k]
cards.append(f'<article><span>{H(k)}</span><b>{H(headline)}</b>'+(f'<p>{H(note)}</p>' if note else '')+'</article>')
return '<div class="v92-summary3 v924-decision3">'+''.join(cards)+'</div>'
def _overview(exp, raw, base, sales):
cards=[]
source=arr((exp.get('ownerOverview') or {}).get('cards'))
preferred={'商品定位':None,'主要产品资产':None,'下一款优先方向':None}
for x in source:
if not isinstance(x,dict):continue
label=clean_display(x.get('label'),18);headline=clean_display(x.get('headline'),42)
if not headline:continue
low=label+headline
if preferred['商品定位'] is None and any(k in low for k in ('定位','场景','人群','商品定义')):preferred['商品定位']=headline
elif preferred['主要产品资产'] is None and any(k in low for k in ('产品','材质','资产','体验','参数')):preferred['主要产品资产']=headline
elif preferred['下一款优先方向'] is None and any(k in low for k in ('下一款','动作','优化','调整')):preferred['下一款优先方向']=headline
pd=(exp.get('productValue') or {}).get('items') or []
for x in pd:
if not isinstance(x,dict):continue
typ=clean_display(x.get('type'),16);val=clean_display(x.get('value'),42)
if typ=='场景价值' and not preferred['商品定位']:preferred['商品定位']=val
if typ=='产品价值' and not preferred['主要产品资产']:preferred['主要产品资产']=val
if typ=='可强化方向' and not preferred['下一款优先方向']:preferred['下一款优先方向']=val
title=clean_display((raw.get('product') or {}).get('title'),60)
attrs=arr(raw.get('attributes'))
attr_text=' / '.join(clean_display(x.get('value'),30) for x in attrs[:6] if isinstance(x,dict) and clean_display(x.get('value'),30))
preferred['商品定位']=preferred['商品定位'] or title or '当前商品定位以页面事实为准'
preferred['主要产品资产']=preferred['主要产品资产'] or clean_display(attr_text,70) or f'已采 {base.get("attributeCount",0)} 项商品参数'
preferred['下一款优先方向']=preferred['下一款优先方向'] or '优先把规格、图片表达和商品页面口径统一'
labels=[('商品定位',preferred['商品定位'],'先看这款商品解决什么场景'),('主要产品资产',preferred['主要产品资产'],'只保留当前证据可以确认的资产'),('下一款优先方向',preferred['下一款优先方向'],'把分析转成下一款动作')]
return '<div class="v92-summary3">'+''.join(f'<article><span>{H(l)}</span><b>{H(v)}</b><p>{H(n)}</p></article>' for l,v,n in labels)+'</div>'
def _title_v92(raw, exp, a):
block=exp.get('titleAnalysis') or (a.get('merchandisingDecision') or {}).get('titleAnalysis') or {}
title=clean_display(block.get('originalTitle') or (raw.get('product') or {}).get('title'),130)
opinion=first_text(block.get('professionalOpinion'),block.get('thesis'),limit=58) or '标题优先统一场景、规格与关键品质参数。'
current=[]
for x in arr(block.get('currentExpression'))[:6]:
if isinstance(x,dict): t=first_text(x.get('value'),x.get('text'),limit=10)
else: t=clean_display(x,14)
if t and t not in current: current.append(t)
if not current:
for kw in ('大学生宿舍','学生宿舍','六件套','七件套','全棉','纯棉','40支','四季'):
if kw in title: current.append(kw)
reinforce=[]
for x in arr(block.get('reinforce'))[:5]:
t=first_text(x.get('topic') if isinstance(x,dict) else x,limit=14)
if t and t not in reinforce: reinforce.append(t)
actions=[first_text(x.get('action') if isinstance(x,dict) else x,limit=28) for x in arr(block.get('recommendedActions'))[:2]]
if actions and not reinforce: reinforce=actions
return _opinion(opinion)+f'<div class="v92-title-current"><small>当前标题</small>{H(title)}</div><div class="v924-title2"><article><span>已表达</span><b>{H(" · ".join(current[:5]) or "按当前标题识别")}</b></article><article><span>建议强化</span><b>{H(" · ".join(reinforce[:5]) or (actions[0] if actions else "规格统一 · 品质参数"))}</b></article></div>'
def _main_v92(exp,a,idx):
block=exp.get('visualCommerce') or {}; vd=a.get('visualDecision') or {}
opinion=first_text(block.get('professionalOpinion'),(vd.get('visualThesis') or {}).get('professionalOpinion'),limit=58) or '主图优先让用户看懂商品信息,再决定是否展开详细分析。'
items=arr(block.get('items')) or arr(vd.get('imageRoles')) or arr(vd.get('mainImageContent'))
byid={safe(x.get('imageEvidenceId')):x for x in items if isinstance(x,dict)}
cards=[]
for n,(eid,e) in enumerate(_entries(idx,'main',5),1):
x=byid.get(eid,{})
role=first_text(x.get('imageRole'),x.get('role'),x.get('contentTask'),limit=8) or f'主图{n}'
signals=arr(x.get('visualSignals'))
if not signals:
# Compatibility: old message becomes one compact signal, never a paragraph.
msg=first_text(x.get('message'),x.get('observation'),x.get('productSubject'),limit=14)
if msg: signals=[msg]
action=first_text(x.get('nextAction'),x.get('professionalAdvice'),x.get('nextProductAction'),x.get('action'),limit=18)
copy=f'<div class="v925-image-info"><div class="v925-row-title"><h3>{H(role)}</h3><span>主图 {n:02d}</span></div>{_chips(signals)}'+(f'<p class="v925-action">专业动作 · {H(action)}</p>' if action else '')+'</div>'
cards.append('<article class="v92-main-card v925-main-card">'+_visual_figure(e,'商品主图 '+str(n),'main-ratio')+copy+'</article>')
continuity=_continuity_v92(exp,a)
folded=f'<details class="v924-details"><summary>查看主图 × 详情 × 评论承接关系</summary>{continuity}</details>' if continuity else ''
return _opinion(opinion)+'<div class="v92-main-grid">'+''.join(cards)+'</div>'+folded
def _status_label(kind,value):
v=safe(value)
maps={
'main':{'expressed':('已表达','ok'),'partial':('部分表达','mid'),'missing':('未覆盖','none')},
'detail':{'proven':('已证明','ok'),'partial':('部分证明','mid'),'missing':('未覆盖','none')},
'review':{'validated':('有验证','ok'),'partial':('部分验证','mid'),'missing':('暂无验证','none')},
}
if v in maps.get(kind,{}):return maps[kind][v]
low=v.lower()
if any(k in low for k in ('closed','已表达','已证明','有反馈','已解释','有验证')):return ('已覆盖','ok')
if any(k in low for k in ('partial','有限','部分','pending','待')):return ('部分','mid')
if not v:return ('—','none')
return (clean_display(v,18),'mid')
def _continuity_v92(exp,a):
block=exp.get('expressionContinuity') or {};checks=arr(block.get('coreClaims')) or arr((a.get('detailDecision') or {}).get('continuityChecks'))
rows=[]
for x in checks[:6]:
if not isinstance(x,dict):continue
claim=clean_display(x.get('claim'),30)
if not claim:continue
ml,mc=_status_label('main',x.get('mainStatus') or x.get('mainImageRole') or ('expressed' if x.get('mainImageEvidenceIds') else ''))
dl,dc=_status_label('detail',x.get('detailStatus') or x.get('detailProof') or ('proven' if x.get('detailEvidenceIds') else ''))
rl,rc=_status_label('review',x.get('reviewStatus') or x.get('reviewValidation'))
adv=first_text(x.get('professionalAdvice'),x.get('nextProductAction'),x.get('gap'),limit=45)
rows.append(f'<tr><td><b>{H(claim)}</b></td><td class="{mc}">{H(ml)}</td><td class="{dc}">{H(dl)}</td><td class="{rc}">{H(rl)}</td><td>{H(adv or "继续保持页面口径一致")}</td></tr>')
if not rows:return ''
return '<div class="v92-table-wrap"><table class="v92-matrix"><thead><tr><th>核心表达</th><th>主图</th><th>详情</th><th>评论</th><th>动作</th></tr></thead><tbody>'+''.join(rows)+'</tbody></table></div>'
def _detail_v92(exp,a,idx):
block=exp.get('detailCommerce') or {}; dd=a.get('detailDecision') or {}
opinion=first_text(block.get('professionalOpinion'),(dd.get('detailThesis') or {}).get('professionalOpinion'),limit=58) or '详情页优先展示能证明商品价值的代表图。'
groups=arr(block.get('contentGroups')) or arr(dd.get('contentGroups'))
entries=dict(_entries(idx,'detail',30)); cards=[]
for i,x in enumerate(groups[:6],1):
if not isinstance(x,dict): continue
eid=safe(x.get('representativeImageId') or x.get('imageEvidenceId')); e=entries.get(eid)
if not e and entries: e=list(entries.values())[min(i-1,len(entries)-1)]
if not e: continue
typ=first_text(x.get('type'),x.get('stage'),limit=10) or '详情信息'
signals=arr(x.get('visualSignals'))
if not signals:
msg=first_text(x.get('message'),x.get('businessMeaning'),limit=14)
if msg: signals=[msg]
action=first_text(x.get('nextAction'),x.get('professionalAdvice'),x.get('nextPageAction'),limit=24)
cards.append('<article class="v925-detail-block">'+_visual_figure(e,typ,'wide-ratio')+f'<div class="v925-detail-copy"><h3>{H(typ)}</h3>{_chips(signals)}'+(f'<p class="v925-action">专业动作 · {H(action)}</p>' if action else '')+'</div></article>')
collected=len(entries)
note=f'<div class="v92-evidence-note">详情图 {collected} 张 · 默认展示 {len(cards)} 个信息任务代表图;其余图片保留为证据。</div>'
return _opinion(opinion)+('<div class="v925-detail-list">'+''.join(cards)+'</div>' if cards else '<div class="v92-empty">详情图片已采集,当前未形成可靠分组。</div>')+note
def _sku_dimensions(raw):
product=raw.get('product') or {};title=safe(product.get('title'));attrs=arr(raw.get('attributes'));sku=arr(raw.get('sku'))
dims=[]
def add(name,vals,source=''):
vals=[clean_display(x,24) for x in vals if clean_display(x,24)]
if vals and name not in [d[0] for d in dims]:dims.append((name,vals[:5]))
if '床单' in title or '床笠' in title:add('款式',[x for x in ('床单','床笠') if x in title])
for x in attrs:
if not isinstance(x,dict):continue
n=safe(x.get('name'));v=safe(x.get('value'))
if any(k in n for k in ('尺寸','床尺寸','规格')) and v:add('尺寸',[v])
elif any(k in n for k in ('颜色','花色','图案','款式')) and v:add('花色/款式',[v])
elif any(k in n for k in ('件数','套件','组合')) and v:add('套件',[v])
if sku:
names=[]
for x in sku[:20]:
if isinstance(x,dict):
val=first_text(x.get('name'),x.get('title'),x.get('value'),x.get('properties'),limit=40)
if val:names.append(val)
if names:add('SKU规格',names)
return dims[:4]
def _sku_v92(exp,a,raw,idx):
block=exp.get('skuAnalysis') or a.get('skuVisualAnalysis') or {}
entries=_entries(idx,'sku',30); structured=arr(raw.get('sku'))
opinion=first_text(block.get('professionalOpinion'),limit=90)
dims=[]
for x in arr(block.get('selectionDimensions')):
if not isinstance(x,dict):continue
name=clean_display(x.get('name'),18);values=[clean_display(v,24) for v in arr(x.get('values')) if clean_display(v,24)]
if name:dims.append((name,values))
if not dims:dims=_sku_dimensions(raw)
order=[clean_display(x,18) for x in arr(block.get('recommendedOrder')) if clean_display(x,18)] or [x[0] for x in dims]
if not opinion:
opinion='当前SKU更适合按清晰选择层级组织:先解决规格是否适配,再处理组合和外观偏好。'
mosaic=''.join(_visual_figure(e,'SKU '+str(i+1),'square-ratio') for i,(eid,e) in enumerate(entries[:8]))
steps=''.join(f'<div class="v92-choice-row"><span>{i:02d}</span><b>{H(name)}</b>'+(f'<small>{H(" / ".join(vals[:3]))}</small>' if vals else '')+'</div>' for i,(name,vals) in enumerate(dims or [(x,[]) for x in order],1))
advice=first_text(block.get('recommendedAction'),limit=55) or ('建议按“'+' → '.join(order)+'”分层选择。' if order else '建议先建立规格层级,再展示外观选择。')
status='结构化SKU '+(str(len(structured))+' 个' if structured else '未完整采集')+f' · SKU图片 {len(entries)} 张'
return _opinion(opinion)+f'<div class="v92-sku-layout"><div class="v92-sku-mosaic">{mosaic}</div><div class="v92-choice">{steps}<div class="v92-choice-note">{H(advice)}</div><small class="v92-status">{H(status)};SKU图片数量不等于SKU数量。</small></div></div>'
def _consumer_v92(exp,a,idx):
block=exp.get('customerExperience') or {}; cr=a.get('consumerResearch') or {}; ci=block.get('consumerInsights') or {}
opinion=first_text(block.get('professionalOpinion'),(cr.get('consumerThesis') or {}).get('statement'),limit=58) or '消费者反馈只保留可指导下一款的真实体验信号。'
signals=arr(ci.get('experienceSignals'))
if not signals:
mapping=[('稳定体验',cr.get('satisfactionDrivers')),('体验差异',cr.get('expectationGaps')),('购前关注',cr.get('prePurchaseConcerns'))]
for typ,values in mapping:
for x in arr(values)[:1]: signals.append({'type':typ,'insight':text_of(x)})
buyer=_entries(idx,'buyerShow',12)
gallery=''.join(_visual_figure(e,'买家秀 '+str(i+1),'square-ratio') for i,(eid,e) in enumerate(buyer[:4]))
rows=[]
for x in signals[:4]:
if not isinstance(x,dict): continue
typ=clean_display(x.get('type'),16) or '消费者信号'; ins=first_text(x.get('insight'),x.get('statement'),x.get('driver'),x.get('problem'),limit=38)
if ins: rows.append(f'<article><span>{H(typ)}</span><b>{H(ins)}</b></article>')
gallery_html=gallery or '<div class="v92-empty">未采集买家秀图片</div>'
rows_html=''.join(rows) or '<div class="v92-empty">当前评论尚未形成可靠聚合洞察</div>'
return _opinion(opinion)+f'<div class="v92-review-layout"><div class="v92-buyer-grid">{gallery_html}</div><div class="v92-insights">{rows_html}</div></div>'
def _value_v92(exp,a):
block=exp.get('productValue') or {};items=arr(block.get('items'))
if not items:
pd=a.get('productDecision') or {};pillars=arr(pd.get('valuePillars'))
for x in pillars[:3]:
if isinstance(x,dict):items.append({'type':clean_display(x.get('name'),18) or '产品价值','value':first_text(x.get('customerValue'),x.get('businessMeaning'),limit=40),'explanation':first_text(x.get('mustKeep'),limit=55)})
if not items:
items=[{'type':'场景价值','value':'当前商品使用场景','explanation':'以标题、参数与图片证据为准'},{'type':'产品价值','value':'当前商品核心配置','explanation':'只保留页面已确认参数'},{'type':'服务价值','value':'页面已展示服务','explanation':'不推断未采集服务'},{'type':'可强化方向','value':'规格与证明表达','explanation':'优先保证图片、参数、SKU口径一致'}]
cards=[]
for x in items[:4]:
if not isinstance(x,dict):continue
typ=clean_display(x.get('type'),18);val=first_text(x.get('value'),x.get('customerValue'),limit=42);desc=first_text(x.get('explanation'),x.get('commercialValue'),x.get('mustKeep'),limit=58)
if val:cards.append(f'<article><span>{H(typ)}</span><b>{H(val)}</b>'+(f'<p>{H(desc)}</p>' if desc else '')+'</article>')
return _opinion(block.get('professionalOpinion'))+'<div class="v92-value-grid">'+''.join(cards)+'</div>'
def _plan_action_list(values, limit=3):
out=[]
for x in arr(values)[:limit]:
t=first_text(x.get('action') if isinstance(x,dict) else x,x.get('item') if isinstance(x,dict) else '',limit=55)
if t:out.append(t)
return out
def _plans_v92(exp,a,idx):
block=exp.get('newProductPlans') or {}; source=arr(block.get('plans')) or arr((a.get('launchPlans') or {}).get('plans'))
opinion=first_text(block.get('professionalOpinion'),(a.get('launchPlans') or {}).get('professionalOpinion'),limit=58) or '优先从证据最强的优化方向开始,再看反馈升级与探索方向。'
cards=[]
for i,x in enumerate(source[:3],1):
if not isinstance(x,dict): continue
source_type=first_text(x.get('sourceType'),limit=10)
typ=first_text(x.get('type'),limit=14)
if not source_type:
source_type='证据驱动优化' if i==1 else ('反馈驱动升级' if i==2 else '探索性方向')
name=first_text(x.get('name'),limit=22) or ('新品方向 '+str(i))
why=first_text(x.get('whyThisPlan'),x.get('positioning'),limit=48)
product=first_text(x.get('productAction'),limit=38)
if not product:
acts=_plan_action_list(x.get('productChanges'),1) or _plan_action_list(x.get('mustChange'),1); product=acts[0] if acts else ''
page=first_text(x.get('pageAction'),limit=38)
if not page:
main=_plan_action_list(x.get('mainImagePlan'),1); detail=_plan_action_list(x.get('detailPagePlan'),1); page=';'.join((main+detail)[:2])
refs=arr(x.get('visualReferenceImageIds')); ref_e=None
for eid in refs:
if eid in idx and (idx[eid].get('meta') or {}).get('group') in ('main','detail','sku'): ref_e=idx[eid]; break
if not ref_e:
mains=_entries(idx,'main',5); ref_e=mains[min(i-1,len(mains)-1)][1] if mains else None
visual=_visual_figure(ref_e,'方案参考图','plan-ratio') if ref_e else '<div class="v92-plan-placeholder">当前商品视觉参考</div>'
body=f'<div class="v925-plan-body"><div class="v925-plan-eyebrow">方案 {chr(64+i)} · {H(source_type)}</div><h3>{H(name)}</h3><dl>'+(f'<div><dt>产品</dt><dd>{H(product)}</dd></div>' if product else '')+(f'<div><dt>页面</dt><dd>{H(page)}</dd></div>' if page else '')+'</dl></div>'
cards.append(f'<article class="v925-plan-card">{visual}{body}</article>')
if not cards: return _opinion(opinion)+'<div class="v92-empty">当前证据未形成可追溯新品方案。</div>'
return _opinion(opinion)+'<div class="v925-plan-grid">'+''.join(cards)+'</div>'
def render(a, task_id):
a=ensure_experience_solution(a)
if not (a.get('meta') or {}).get('reportReady'): raise ValueError((a.get('meta') or {}).get('reportBlockedReason') or '本次没有可输出的证据结论')
raw=a.get('facts') or {}; product=raw.get('product') or {}; sales=raw.get('sales') or {}; base=a.get('baseline') or {}; exp=a.get('experienceSolution') or {}; idx=evidence_index(a)
summary=exp.get('reportSummary') or {}
title=clean_display(summary.get('title') or product.get('title') or '商品分析报告',60)
verdict=first_text(summary.get('verdict'),limit=58) or '基于当前商品页面、图片、参数与评论证据形成开品建议。'
cover_id=safe(summary.get('coverImageId')); cover_e=idx.get(cover_id) if cover_id else None
if not cover_e:
mains=_entries(idx,'main',1); cover_e=mains[0][1] if mains else None
price='¥'+safe(sales.get('currentPrice')) if safe(sales.get('currentPrice')) else '价格未采集'
sold=safe(sales.get('sold') or sales.get('cumulativeSales')) or '销量未采集'
meta_line=f'{price} · 公开销量 {sold} · 评论 {base.get("reviewCount",0)} · SKU图 {len(_entries(idx,"sku",999))} · 参数 {base.get("attributeCount",0)}'
nav='<nav class="v92-nav"><div class="wrap"><div class="brand">三笙 · 商品开品分析</div><div class="v92-links"><a href="#decision">老板速览</a><a href="#plan">新品方向</a><a href="#title">标题</a><a href="#main">主图</a><a href="#detail">详情</a><a href="#sku">SKU</a><a href="#review">评价</a></div></div></nav>'
cover=_visual_figure(cover_e,'商品封面图','cover-ratio') if cover_e else '<div class="v92-cover-empty">主图未采集</div>'
hero=f'<header class="v92-hero"><div class="wrap v92-hero-grid"><div><div class="v92-kicker">PRODUCT DECISION REPORT · BIG VISUAL · V9.2.5</div><h1>{H(title)}</h1><p class="v92-meta">{H(meta_line)}</p><div class="v925-hero-claim">{H(clean_display(verdict,45))}</div></div><div class="v92-cover">{cover}</div></div></header>'
doc='<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>'+H(title)+'</title><link rel="stylesheet" href="/report.css"></head><body>'+nav+hero+_section('01','老板 30 秒看懂','只看结果,不看分析过程',_owner_overview(exp,raw,base,sales),'decision')+_section('02','下一款先做什么','先给决策,再看证据',_plans_v92(exp,a,idx),'plan')+_section('03','标题只解决一个问题:说清楚','保留表达与动作',_title_v92(raw,exp,a),'title')+_section('04','主图到底在卖什么','图片本身就是成交语言',_main_v92(exp,a,idx),'main')+_section('05','详情页证明了什么','大图展示证明内容',_detail_v92(exp,a,idx),'detail')+_section('06','先解决适配,再解决审美','SKU看选择路径',_sku_v92(exp,a,raw,idx),'sku')+_section('07','消费者真正感受到什么','买家秀是证据',_consumer_v92(exp,a,idx),'review')+f'<footer class="v92-footer"><div class="wrap">Task {H(task_id)} · 三笙商品开品分析报告引擎 V9.2.5 · 未采集经营指标不作推断</div></footer><script>document.addEventListener("error",e=>{{if(e.target&&e.target.tagName==="IMG"){{const f=e.target.closest("figure.visual");if(f)f.classList.add("failed");e.target.style.display="none";}}}},true);</script></body></html>'
return doc
+303
View File
@@ -0,0 +1,303 @@
:root{
--paper:#fff;--paper2:#f5f2ee;--ink:#171614;--muted:#6b6760;
--line:#ddd8d1;--dark:#181715;--dark2:#24211d;--cream:#ebe4db;
--good:#3f6f58;--risk:#9a4d43;--ask:#9a7a4f;--accent:#b88b5b
}
*{box-sizing:border-box}html{scroll-behavior:smooth}
body{margin:0;background:var(--paper2);color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC","Helvetica Neue",Arial,sans-serif}
.wrap{width:min(1380px,calc(100% - 72px));margin:auto}
nav{height:64px;position:sticky;top:0;z-index:40;background:rgba(255,255,255,.95);backdrop-filter:blur(14px);border-bottom:1px solid var(--line)}
.navin{height:100%;display:flex;align-items:center;justify-content:space-between}.brand{font-size:13px;font-weight:800}
.navlinks{display:flex;gap:5px}.navlinks a{text-decoration:none;font-size:11px;color:#6f685f;padding:8px 10px;border-radius:999px}.navlinks a:hover{background:#201e1b;color:#fff}
.hero{min-height:0;background:linear-gradient(180deg,#fff 0%,#f8f4ef 100%);padding:72px 0 60px}
.hero-grid{display:grid;grid-template-columns:1fr .72fr;gap:58px;align-items:center}
.hero-grid.text-only{grid-template-columns:minmax(0,1100px)}
.kicker{font-size:11px;letter-spacing:0;color:#938980;margin-bottom:16px}
h1{margin:0;font-size:56px;line-height:1.06;letter-spacing:0;font-weight:720}
.hero-copy{font-size:19px;line-height:1.78;color:#625c55;max-width:900px;margin:28px 0 0}
.hero-copy-block h1{max-width:840px}.fact-line{font-size:13px;line-height:1.6;color:#6b645d;margin:24px 0 0}.hero-verdict{margin-top:34px;padding:20px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.hero-verdict span,.hero-actions>span{display:block;font-size:10px;color:#918981;margin-bottom:8px}.hero-verdict strong{display:block;font-size:25px;line-height:1.35}.hero-actions{margin-top:22px}.hero-actions .gate{padding:9px 0;grid-template-columns:155px 1fr}.hero-actions .gate b{font-size:12px}.hero-actions .gate span{font-size:13px;line-height:1.5}
.hero-card{background:#f5f7f6;border:1px solid var(--line);padding:27px}
.hero-card span{font-size:10px;color:#8e857c}.hero-card strong{display:block;font-size:35px;line-height:1.22;margin:10px 0}
.hero-card p{font-size:14px;line-height:1.72;color:#68615a;margin:0}
.hero-kpis{display:grid;grid-template-columns:repeat(2,1fr);border-left:1px solid var(--line);border-top:1px solid var(--line);margin-top:22px}
.hero-kpi{background:#fff;padding:15px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}
.hero-kpi small{font-size:10px;color:#918981}.hero-kpi b{display:block;font-size:23px;margin-top:5px}
.chapter{padding:68px 0}.white{background:#fff}.soft{background:#f4f6f5}.ink{background:var(--dark);color:#fff}
.section-head{display:grid;grid-template-columns:1fr .47fr;gap:44px;align-items:end;margin-bottom:40px}
.section-index{display:flex;gap:19px}.section-index>span{font-size:12px;color:#958c83;margin-top:9px}
.section-kicker{display:block;font-size:10px;letter-spacing:0;color:#948a81;margin-bottom:9px}
h2{font-size:44px;line-height:1.16;letter-spacing:0;margin:0}
.section-head.compact{display:block;margin-bottom:24px}.section-head.compact h2{font-size:48px;max-width:1050px}.evidence-note{font-size:12px;color:#8a8178;margin:0 0 24px}
.section-head>p{font-size:15px;line-height:1.72;color:#6b645d;margin:0}.ink .section-head>p{color:#c5bdb5}
.shopper-decision{border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
.decision-boundary{display:grid;grid-template-columns:repeat(4,1fr);border-top:1px solid var(--line);border-left:1px solid var(--line);margin-bottom:24px}
.decision-boundary article{padding:17px;background:#f7f8f7;border-right:1px solid var(--line);border-bottom:1px solid var(--line);min-width:0}
.decision-boundary span{display:block;font-size:10px;color:#817970}.decision-boundary strong{display:block;font-size:15px;line-height:1.48;margin:7px 0;overflow-wrap:anywhere}.decision-boundary small{display:block;font-size:10px;line-height:1.5;color:#777f7a}
.shopper-verdict{display:grid;grid-template-columns:120px 1fr;gap:18px;padding:24px 0;border-bottom:1px solid var(--line);align-items:start}
.shopper-verdict>span{font-size:10px;color:#7f776f;margin-top:6px}.shopper-verdict strong{font-size:24px;line-height:1.4}.shopper-verdict small{grid-column:2;font-size:11px;color:#77706a;margin-top:-8px}
.shopper-groups{display:grid;grid-template-columns:1fr 1fr}.shopper-group{padding:24px 26px 26px 0;border-bottom:1px solid var(--line)}.shopper-group:nth-child(even){padding-left:26px;padding-right:0;border-left:1px solid var(--line)}.shopper-group.full{grid-column:1/-1;padding-left:0;border-left:0}.shopper-group.full .shopper-grid{grid-template-columns:repeat(3,1fr)}
.shopper-group h3{font-size:16px;margin:0 0 14px}.shopper-grid{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--line);border-left:1px solid var(--line)}
.shopper-row{padding:14px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);min-width:0}.shopper-row strong{display:block;font-size:12px;line-height:1.4}.shopper-row p{font-size:14px;line-height:1.55;margin:6px 0 0;overflow-wrap:anywhere}.shopper-meta{display:block;font-size:9px;color:#817970;margin-bottom:7px}.shopper-row.check{background:#f3f6f4}.shopper-row.question{background:#faf8f4}
.owner-thesis{font-size:22px;line-height:1.45;margin:-8px 0 12px;max-width:980px}
.owner{border:1px solid var(--line);background:#faf8f5}
.owner>header{padding:28px 30px;border-bottom:1px solid var(--line)}
.owner>header>span{font-size:10px;color:#8f867d}.owner>header h3{font-size:34px;line-height:1.3;letter-spacing:0;margin:9px 0}
.owner>header p{font-size:13px;color:#736b63;margin:0}
.owner-grid{display:grid;grid-template-columns:repeat(3,1fr)}
.owner-card{background:#fff;padding:21px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);min-height:0}.owner-card strong{font-size:21px;line-height:1.38}.owner-card ul{padding-left:16px;margin:12px 0 0}.owner-card li{font-size:12px;line-height:1.5;margin:4px 0}
.owner-card:nth-child(3n){border-right:0}.owner-card.wide{grid-column:span 2}
.owner-card span{font-size:10px;color:#918981}.owner-card strong{display:block;font-size:27px;line-height:1.3;margin:10px 0}
.owner-card p{font-size:14px;line-height:1.72;color:#68625b;margin:0}.owner-card ul{padding-left:18px;margin:13px 0 0}
.owner-card li{font-size:13px;line-height:1.58;margin:5px 0;color:#5e5852}.owner-card.good strong{color:var(--good)}.owner-card.risk strong{color:var(--risk)}
.story{margin-top:28px}.story-item{display:grid;grid-template-columns:.9fr 1.1fr;border-top:1px solid var(--line);padding:25px 0;gap:38px;align-items:start}.story-item:last-child{border-bottom:1px solid var(--line)}
.story-media{margin:0;background:#fff;border:1px solid var(--line);display:flex;justify-content:center;align-items:flex-start;padding:10px}.story-media img{width:100%;height:auto;object-fit:contain;display:block}
.story-copy>span{font-size:10px;color:#918981}.story-copy h3{font-size:25px;line-height:1.3;margin:9px 0 18px}.story-copy>p{font-size:15px;line-height:1.75;color:#68625b}
.story-copy .source-badge{display:inline-block;color:#405e4b;border:1px solid #b9c8bf;padding:6px 8px;background:#f3f7f4}
.evidence{display:grid;grid-template-columns:1fr;border-left:1px solid var(--line);border-top:1px solid var(--line);margin-top:18px}
.evidence div{padding:13px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}
.evidence span{display:block;font-size:10px;color:#918981}.evidence b{display:block;font-size:13px;line-height:1.55;margin-top:4px}
.evidence .action{background:#eef2f0}
.param-strip{display:grid;grid-template-columns:repeat(4,1fr);border-left:1px solid var(--line);border-top:1px solid var(--line)}
.param{background:#fff;padding:16px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.param span{font-size:10px;color:#918981}.param b{display:block;font-size:13px;line-height:1.55;margin-top:6px}
.voice-stats{display:grid;grid-template-columns:repeat(6,1fr);border:1px solid #4a443e}
.voice-stat{padding:17px;border-right:1px solid #4a443e}.voice-stat:last-child{border-right:0}
.voice-stat span{display:block;font-size:10px;color:#aaa199}.voice-stat strong{display:block;font-size:28px;margin:6px 0}.voice-stat small{font-size:10px;color:#bbb2aa}
.result-block{margin-top:28px}.result-block h3{font-size:17px;margin:0 0 12px}.result-grid{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid #4a443e;border-left:1px solid #4a443e}
.result-row{padding:16px;border-right:1px solid #4a443e;border-bottom:1px solid #4a443e;min-width:0}.result-row>span{display:block;font-size:9px;color:#aaa199;margin-bottom:7px}.result-row strong{display:block;font-size:15px;line-height:1.4}.result-row p{font-size:13px;line-height:1.6;color:#d1c9c1;margin:7px 0 0;overflow-wrap:anywhere}.result-row.positive{background:#29312c}.result-row.risk{background:#342724}.result-row.question{background:#302d27}
.journey-wrap{margin-top:28px;border:1px solid #4a443e;background:#282520}
.journey-head{padding:22px;border-bottom:1px solid #4a443e}.journey-head span{font-size:10px;color:#aaa199}.journey-head h3{font-size:25px;line-height:1.28;margin:8px 0}.journey-head p{font-size:14px;line-height:1.7;color:#c4bcb4;margin:0}
.journey-tabs{display:grid;grid-template-columns:repeat(7,1fr);border-bottom:1px solid #4a443e}
.jtab{padding:15px;border-right:1px solid #4a443e;background:#24211e;color:#d2cac2;cursor:pointer}.jtab:last-child{border-right:0}.jtab.active{background:#fff;color:#1a1815}
.jtab small{font-size:9px;display:block;opacity:.65}.jtab b{font-size:13px;display:block;margin-top:5px}
.jpanel{display:none;padding:22px}.jpanel.active{display:block}
.jpanel>header span{font-size:10px;color:#aaa199}.jpanel>header h4{font-size:28px;margin:8px 0}.jpanel>header p{font-size:14px;color:#c5bdb5}
.jgrid{display:grid;grid-template-columns:repeat(4,1fr);border-left:1px solid #4a443e;border-top:1px solid #4a443e;margin-top:20px}
.jgrid section{padding:15px;border-right:1px solid #4a443e;border-bottom:1px solid #4a443e}.jgrid span{font-size:10px;color:#aaa199}.jgrid p{font-size:13px;line-height:1.6;color:#d1c9c1;margin:5px 0 0}
.jmeta{display:flex;gap:9px;margin-top:16px}.jmeta span{font-size:10px;border:1px solid #514a43;padding:6px 9px;border-radius:999px;color:#c7bfb7}
.quotes{margin:16px 0 0;padding-left:20px}.quotes li{font-size:13px;color:#cfc7bf;line-height:1.65;margin:7px 0}
.buyer-evidence{margin-top:24px;border:1px solid #4a514d;background:#252a27;padding:22px}.buyer-evidence>header{padding:0 0 18px;border-bottom:1px solid #4a514d}.buyer-evidence>header span{font-size:10px;color:#aeb8b2}.buyer-evidence>header h3{font-size:24px;margin:7px 0}.buyer-evidence>header p{font-size:12px;line-height:1.55;color:#aeb8b2;margin:0}.buyer-groups{display:grid;gap:18px;padding-top:18px}.buyer-review-group{display:grid;grid-template-columns:minmax(220px,300px) minmax(0,1fr);gap:18px;border:1px solid #48504b;background:#202522;padding:16px;min-width:0}.buyer-review-copy{padding:4px}.buyer-review-copy span{font-size:10px;color:#aeb8b2}.buyer-review-copy blockquote{font-size:14px;line-height:1.65;margin:10px 0;color:#f0f3f1;overflow-wrap:anywhere}.buyer-review-copy small{display:block;font-size:10px;line-height:1.5;color:#aeb8b2}.buyer-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;align-items:start}.buyer-gallery figure{margin:0;background:#fff;min-width:0}.buyer-gallery img{width:100%;height:auto;object-fit:contain;display:block}.buyer-gallery figcaption{padding:7px 8px;color:#68716c;background:#eef1ef;font-size:9px;border-top:1px solid #d8dedb}
.signal-atlas{margin-top:24px;border:1px solid #4a443e;padding:22px;background:#26231f}
.signal-atlas span.label{font-size:10px;color:#aaa199}.signal-atlas h3{font-size:30px;margin:8px 0}.signal-atlas p{font-size:14px;color:#c5bdb5}
.signals{display:flex;flex-wrap:wrap;gap:8px;margin-top:18px}.signal{font-size:12px;border:1px solid #575049;border-radius:999px;padding:8px 11px}.signal.good{color:#bfd4bd}.signal.ask{color:#ddc9a9}.signal.risk{color:#e2b1a9}
.product-box{display:grid;grid-template-columns:.82fr 1.18fr;gap:25px}
.param-card,.product-conclusion{background:#fff;border:1px solid var(--line);padding:25px}
.param-card h3,.product-conclusion h3{font-size:31px;line-height:1.28;margin:0 0 17px}
.prow{display:grid;grid-template-columns:135px 1fr;gap:18px;padding:13px 0;border-top:1px solid var(--line)}.prow:first-of-type{border-top:0}
.prow span{font-size:10px;color:#918981}.prow b{font-size:14px;line-height:1.6}
.product-conclusion>p{font-size:15px;line-height:1.76;color:#68625b}
.gate{padding:14px 0;border-top:1px solid var(--line);display:grid;grid-template-columns:145px 1fr;gap:20px}.gate b{font-size:13px}.gate span{font-size:14px;line-height:1.65;color:#67615a}
.plans{display:grid;grid-template-columns:1fr 1fr;gap:20px}
.plan{border:1px solid #4a443e;background:#282520;padding:24px}.plan>span{font-size:10px;color:#aaa199}.plan h3{font-size:27px;line-height:1.25;margin:9px 0}.plan>p{font-size:13px;line-height:1.65;color:#c9c1b9}
.plan-specs{display:grid;grid-template-columns:1fr 1fr;border-left:1px solid #4a443e;border-top:1px solid #4a443e;margin-top:19px}
.plan-specs div{padding:13px;border-right:1px solid #4a443e;border-bottom:1px solid #4a443e}.plan-specs span{font-size:10px;color:#aaa199;display:block}.plan-specs b{font-size:13px;line-height:1.55;display:block;margin-top:4px}
.plan-section{padding:15px 0;border-top:1px solid #4a443e}.plan-section h4{font-size:17px;margin:0 0 6px}.plan-section p{font-size:13px;line-height:1.65;color:#c6beb6;margin:0}
.plan-actions{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-top:18px}.plan-actions h4{font-size:15px;margin:0 0 7px}.action-list{padding-left:17px;margin:0}.action-list li{font-size:12px;line-height:1.55;color:#c6beb6;margin:5px 0}
.commercial{margin-top:26px;border:1px solid #4a443e}.commercial header{padding:21px;border-bottom:1px solid #4a443e}
.commercial header span{font-size:10px;color:#aaa199}.commercial header strong{font-size:27px;display:block;margin-top:6px}
.commercial-grid{display:grid;grid-template-columns:repeat(3,1fr)}.commercial-grid p{padding:20px;margin:0;border-right:1px solid #4a443e}.commercial-grid p:last-child{border-right:0}
.commercial-grid span{font-size:10px;color:#aaa199;display:block}.commercial-grid b{font-size:14px;line-height:1.62;display:block;margin-top:5px}
.governance{background:#181715;color:#fff;border-top:1px solid #2f2b26;border-bottom:1px solid #2f2b26}.governance-grid{display:grid;grid-template-columns:repeat(4,1fr);border-left:1px solid #3b352e}.governance article{padding:16px 18px;border-right:1px solid #3b352e}.governance span{display:block;font-size:9px;color:#bfb4a8;margin-bottom:7px}.governance b{font-size:12px;line-height:1.55;color:#f5efe8}
.selling-points{margin-top:22px;border:1px solid #4d443a;background:#211e1a}.selling-points header{padding:18px 18px 0}.selling-points header span{font-size:9px;color:#bfb4a8}.selling-points h4{font-size:22px;margin:6px 0 14px}.selling-points>div{display:grid;grid-template-columns:repeat(2,1fr);border-top:1px solid #4d443a;border-left:1px solid #4d443a}.selling-points article{padding:16px;border-right:1px solid #4d443a;border-bottom:1px solid #4d443a;min-width:0}.selling-points article strong{display:block;font-size:18px;line-height:1.35;color:#fff}.selling-points article p{font-size:12px;line-height:1.55;color:#d5ccc2;margin:8px 0}.selling-points article small{display:block;font-size:10px;color:#b8ab9c}.selling-points article b{display:block;font-size:12px;line-height:1.55;color:#d8bea1;margin-top:8px}
.profile-board{display:grid;grid-template-columns:repeat(4,1fr);border-left:1px solid var(--line);border-top:1px solid var(--line);background:#fff}.profile-cell{padding:16px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);min-width:0}.profile-cell span,.market-slot span{display:block;font-size:10px;color:#918981}.profile-cell b,.market-slot b{display:block;font-size:14px;line-height:1.55;margin-top:6px;overflow-wrap:anywhere}
.logic-chain{display:grid;grid-template-columns:repeat(5,1fr);gap:12px}.logic-step{background:#fff;border:1px solid var(--line);padding:20px;min-height:180px}.logic-step span{font-size:11px;color:#918981}.logic-step strong{display:block;font-size:22px;line-height:1.28;margin:10px 0}.logic-step p{font-size:13px;line-height:1.6;color:#67615a;margin:0 0 12px}.logic-step small{font-size:10px;color:#8c847b}
.feature-grid{display:grid;grid-template-columns:repeat(4,1fr);border-left:1px solid #4a443e;border-top:1px solid #4a443e}.feature-card{padding:20px;border-right:1px solid #4a443e;border-bottom:1px solid #4a443e;min-height:150px;background:#24211e}.feature-card span{font-size:10px;color:#aaa199}.feature-card strong{display:block;font-size:23px;line-height:1.32;margin:9px 0;color:#fff}.feature-card p{font-size:12px;line-height:1.58;color:#cbc3bb;margin:0}.feature-card.good strong{color:#c6dcc4}.feature-card.risk strong{color:#e0b1a9}
.marketing-grid{display:grid;grid-template-columns:1.05fr .95fr;gap:18px}.marketing-grid>article{background:#fff;border:1px solid var(--line);padding:22px;min-width:0}.marketing-grid>article>span{font-size:10px;color:#918981}.marketing-grid strong{display:block;font-size:26px;line-height:1.35;margin:9px 0 18px}.marketing-grid ul{margin:14px 0 0;padding-left:18px}.marketing-grid li{font-size:14px;line-height:1.65;margin:7px 0}.market-slots{display:grid;grid-template-columns:repeat(2,1fr);border-left:1px solid var(--line);border-top:1px solid var(--line)}.market-slot{padding:14px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}
.visual-states{display:grid;grid-template-columns:repeat(4,1fr);border-left:1px solid var(--line);border-top:1px solid var(--line);margin-bottom:18px}.visual-state{background:#fff;padding:17px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.visual-state span{font-size:10px;color:#918981}.visual-state strong{display:block;font-size:28px;margin:5px 0}.visual-state small{font-size:10px;color:#77706a}.visual-state.ready strong{color:var(--good)}.visual-state.missing strong{color:var(--risk)}
.competition-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.competition-grid article{border:1px solid var(--line);background:#fff;padding:24px;min-height:190px}.competition-grid span{font-size:10px;color:#918981}.competition-grid strong{display:block;font-size:28px;line-height:1.28;margin:9px 0}.competition-grid p{font-size:13px;line-height:1.65;color:#67615a;margin:0}.competition-grid .strong strong{color:var(--good)}.competition-grid .mid strong{color:var(--ask)}.competition-grid .weak strong{color:var(--risk)}
.validation-wrap{margin-top:28px;border:1px solid #4a443e;background:#221f1b;padding:22px;overflow-x:auto}.validation-wrap header{margin-bottom:14px}.validation-wrap span{font-size:10px;color:#aaa199}.validation-wrap h3{font-size:26px;margin:7px 0;color:#fff}.validation-wrap .validation{min-width:980px}
.validation{width:100%;border-collapse:collapse;background:#fff;border:1px solid var(--line)}
.validation th,.validation td{text-align:left;vertical-align:top;padding:13px;border-bottom:1px solid var(--line)}.validation th{font-size:10px;color:#8e857c}.validation td{font-size:12px;line-height:1.55}.validation tr:last-child td{border-bottom:0}
.footer{padding:38px 0 52px;font-size:11px;color:#8c847b}
.reveal{opacity:1;transform:none}
@media(max-width:1000px){.wrap{width:min(1380px,calc(100% - 28px))}.hero-grid,.section-head,.story-item,.product-box,.marketing-grid{grid-template-columns:1fr}.decision-boundary{grid-template-columns:1fr 1fr}.owner-grid,.voice-stats,.journey-tabs,.jgrid,.param-strip,.result-grid,.governance-grid,.profile-board,.feature-grid,.visual-states{grid-template-columns:1fr 1fr}.logic-chain,.competition-grid{grid-template-columns:1fr}.owner-card.wide{grid-column:auto}.plans{grid-template-columns:1fr}.commercial-grid{grid-template-columns:1fr}#validate .wrap{overflow-x:auto}.validation{min-width:980px}.shopper-group.full .shopper-grid{grid-template-columns:1fr 1fr}.buyer-review-group{grid-template-columns:240px minmax(0,1fr)}.buyer-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:680px){nav{height:auto}.navin{min-height:56px}.navlinks{display:none}.hero{padding:42px 0 34px}h1{font-size:38px}.hero-shot{order:-1;padding:8px}.hero-verdict strong{font-size:20px}.hero-actions .gate{grid-template-columns:1fr;gap:4px}.chapter{padding:46px 0}.section-head.compact h2{font-size:34px}.decision-boundary{grid-template-columns:1fr}.owner-grid,.voice-stats,.journey-tabs,.jgrid,.param-strip,.hero-kpis,.plan-specs,.plan-actions,.shopper-groups,.shopper-grid,.shopper-group.full .shopper-grid,.result-grid,.governance-grid,.selling-points>div,.profile-board,.feature-grid,.visual-states,.market-slots{grid-template-columns:1fr}.shopper-verdict{grid-template-columns:1fr;gap:7px}.shopper-verdict small{grid-column:1;margin-top:0}.shopper-group,.shopper-group:nth-child(even),.shopper-group.full{grid-column:1;padding:20px 0;border-left:0}.buyer-evidence{padding:16px}.buyer-review-group{grid-template-columns:1fr;padding:12px}.buyer-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.owner-card{min-height:auto}.section-index{gap:12px}.story-media{height:auto}.story-copy h3{font-size:23px}.owner-thesis{font-size:19px}.logic-step,.feature-card,.competition-grid article{min-height:0}.marketing-grid strong{font-size:22px}}
.hero-shot{min-height:360px;background:#f8f5f1;border:1px solid var(--line);display:flex;align-items:center;justify-content:center;padding:14px;box-shadow:0 24px 80px rgba(39,31,24,.08)}.hero-shot img,.story-media img{width:100%;height:auto;object-fit:contain;display:block}.scroll-progress{position:fixed;left:0;top:0;width:100%;height:2px;z-index:90}.scroll-progress span{display:block;height:100%;background:#1d211f;transform-origin:left;transform:scaleX(0)}
.loading-page{min-height:100vh;background:#eef1ef;padding:34px;color:#171a18}.loading-shell{width:min(1180px,100%);margin:auto;background:#fff;border:1px solid #d8dedb}.loading-head{display:flex;justify-content:space-between;gap:30px;padding:34px 36px 28px;border-bottom:1px solid var(--line)}.loading-head h1{font-size:42px;line-height:1.12;margin:9px 0 12px}.loading-head p{font-size:14px;line-height:1.6;color:#646b67;margin:0;max-width:720px}.loading-head>strong{font-size:42px;line-height:1;color:#556d5a}.loading-evidence,.loading-work{padding:25px 36px;border-bottom:1px solid var(--line)}.loading-section-title{display:flex;justify-content:space-between;align-items:baseline;gap:20px;margin-bottom:15px}.loading-section-title>span{font-size:16px;font-weight:750}.loading-section-title>small{font-size:11px;color:#717873}.loading-stats{display:grid;grid-template-columns:repeat(7,1fr);border-top:1px solid var(--line);border-left:1px solid var(--line)}.loading-stats>div{padding:13px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.loading-stats span{display:block;font-size:10px;color:#727a75}.loading-stats b{display:block;font-size:23px;margin-top:6px}.loading-images{display:grid;grid-template-columns:repeat(8,minmax(0,1fr));gap:8px;margin-top:14px}.loading-images figure{margin:0;border:1px solid var(--line);background:#f5f7f6}.loading-images img{display:block;width:100%;aspect-ratio:3/4;object-fit:contain;background:#fff}.loading-images figcaption{font-size:9px;color:#69706c;padding:6px 7px;border-top:1px solid var(--line)}.loading-steps{display:grid;grid-template-columns:repeat(2,1fr);border-top:1px solid var(--line);border-left:1px solid var(--line)}.loading-step{display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:10px;padding:11px 13px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.loading-step i{width:8px;height:8px;border:1px solid #929a95;border-radius:50%}.loading-step span{font-size:12px}.loading-step b{font-size:10px;color:#7b837e;font-weight:600}.loading-step.done i{background:#556d5a;border-color:#556d5a}.loading-step.done b{color:#556d5a}.loading-step.processing{background:#f1f5f2}.loading-step.processing i{background:#171a18;border-color:#171a18}.loading-step.not_collected{background:#faf8f5}.loading-step.not_collected i{border-color:#a88a64}.loading-step.not_collected b{color:#8d6f4c}.loading-step.failed{background:#fff2f0}.loading-step.failed i{background:#a2463d;border-color:#a2463d}.loading-step.failed b{color:#a2463d}.loading-page .topic-bar{height:5px;background:#e5e9e6;margin:0 36px 28px}.loading-page .topic-bar i{display:block;height:100%;background:#556d5a;transition:width .35s ease}.loading-page .error{color:#a2463d;white-space:pre-wrap;margin:0 36px 28px}.loading-page .eyebrow{font-size:10px;color:#7c847f}
@media(max-width:680px){h1{font-size:38px}}
@media(max-width:800px){.loading-page{padding:0}.loading-shell{border-left:0;border-right:0}.loading-head{padding:25px 18px 22px}.loading-head h1{font-size:32px}.loading-head>strong{font-size:28px}.loading-evidence,.loading-work{padding:21px 18px}.loading-section-title{display:block}.loading-section-title small{display:block;margin-top:6px}.loading-stats{grid-template-columns:repeat(2,1fr)}.loading-images{grid-template-columns:repeat(4,1fr)}.loading-steps{grid-template-columns:1fr}.loading-page .topic-bar{margin:0 18px 24px}}
/* V9:白色阅读背景,青绿到青蓝为品牌强调色。 */
:root{--paper:#fff;--paper2:#f5faf9;--ink:#10191d;--muted:#60757c;--line:#dbe8e8;--dark:#071114;--dark2:#0d2025;--good:#00a98f;--risk:#d34f5f;--ask:#167ca7;--accent:#00cdb0;--accent2:#008fd8;--gradient:linear-gradient(110deg,#00d9b6 0%,#00acd0 56%,#008bd8 100%)}
body{background:#fff;color:var(--ink)}nav{background:rgba(255,255,255,.96);border-color:#dce9e9;box-shadow:0 8px 26px rgba(8,54,63,.06)}
.brand{background:var(--gradient);-webkit-background-clip:text;background-clip:text;color:transparent;font-weight:900}.navlinks a{color:#526d74}.navlinks a:hover{background:#071114;color:#fff}
.hero{background:linear-gradient(180deg,#fff 0%,#f1fbf9 100%);position:relative;overflow:hidden}.hero:before{content:"";position:absolute;inset:0 0 auto auto;width:46%;height:5px;background:var(--gradient)}
.kicker,.section-kicker{color:#008fae;font-weight:800;letter-spacing:.08em}.hero-verdict{border-color:#bfe6e1}.hero-shot{background:#fff;border-color:#cfe4e4;box-shadow:0 28px 70px rgba(0,105,128,.13)}
.scroll-progress span{background:var(--gradient)}.soft{background:#f5faf9}.white{background:#fff}.ink{background:#071114;color:#f2fbfc}.ink .section-kicker{color:#12d8bb}.ink .section-head h2{color:#fff}
.evidence-tags{display:flex;flex-wrap:wrap;gap:8px;margin-top:22px}.evidence-tags b{font-size:10px;padding:7px 10px;border:1px solid #b9e2df;border-radius:999px;background:#fff;color:#24717b}.evidence-tags b:nth-child(2){border-color:#9dd5ef;color:#117ba7}.evidence-tags b:nth-child(3){background:var(--gradient);color:#fff;border:0}.evidence-tags b:nth-child(4){color:#708187}
.title-original{padding:26px 28px;border:1px solid var(--line);background:#071114;color:#fff}.title-original span{display:block;font-size:10px;color:#7fb9c1;margin-bottom:9px}.title-original strong{font-size:25px;line-height:1.5}
.title-segments{display:grid;grid-template-columns:repeat(4,1fr);border-left:1px solid var(--line);border-top:1px solid var(--line)}.title-segments article{padding:18px;background:#fff;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.title-segments span,.analysis-strip span{display:block;font-size:10px;color:#698087}.title-segments strong{display:block;font-size:17px;line-height:1.4;margin:8px 0}.title-segments p{font-size:12px;line-height:1.6;color:#5c7076;margin:0}
.analysis-strip{display:grid;grid-template-columns:repeat(3,1fr);margin-top:18px;border:1px solid #cde4e3;background:#effaf8}.analysis-strip article{padding:18px;border-right:1px solid #cde4e3}.analysis-strip article:last-child{border:0}.analysis-strip b{display:block;margin-top:8px;font-size:13px;line-height:1.6}
.image-analysis-list{display:grid;gap:24px}.image-analysis{display:grid;grid-template-columns:minmax(300px,.9fr) minmax(0,1.1fr);border:1px solid var(--line);background:#fff;box-shadow:0 16px 42px rgba(15,63,72,.07)}.image-analysis figure{margin:0;padding:12px;background:#f7fbfa;border-right:1px solid var(--line)}.image-analysis img{display:block;width:100%;height:auto;max-height:720px;object-fit:contain;background:#fff}.image-analysis figcaption{padding:10px 4px 0;font-size:10px;color:#668087}.image-copy{display:grid;grid-template-columns:1fr 1fr;align-content:start}.image-copy>div{padding:17px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.image-copy span{display:block;font-size:10px;color:#668087}.image-copy b{display:block;font-size:13px;line-height:1.62;margin-top:6px;overflow-wrap:anywhere}
.empty-state{padding:30px;border:1px dashed #a8cdca;background:#f3fbf9;color:#61777d;text-align:center;font-size:13px}.table-scroll{overflow:auto}.table-scroll .validation{min-width:840px}
.asset-levels,.dna-grid,.parameter-grid,.shot-grid{display:grid;grid-template-columns:repeat(4,1fr);border-left:1px solid var(--line);border-top:1px solid var(--line);margin-top:24px}.asset-levels article,.dna-grid article,.parameter-grid article,.shot-grid article{padding:20px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);background:#fff;color:var(--ink)}.asset-levels span,.dna-grid span,.parameter-grid span,.shot-grid span{font-size:10px;color:#008ca7}.asset-levels li,.asset-levels p,.dna-grid p,.parameter-grid p,.shot-grid p{font-size:13px;line-height:1.62;color:#526a70}.shot-grid strong{display:block;font-size:18px;line-height:1.4;margin:9px 0}.shot-grid small{font-size:10px;color:#75898e}.subhead{font-size:22px;margin:34px 0 0}
.ink .plans{grid-template-columns:repeat(3,1fr)}.ink .plan{border-color:#24444a;background:#0d2025}.ink .asset-levels article{background:#0d2025;color:#fff;border-color:#31565a}.ink .asset-levels li,.ink .asset-levels p{color:#bad0d3}
@media(max-width:1000px){.title-segments,.analysis-strip,.asset-levels,.dna-grid,.parameter-grid,.shot-grid{grid-template-columns:1fr 1fr}.image-analysis{grid-template-columns:1fr}.image-analysis figure{border-right:0;border-bottom:1px solid var(--line)}.ink .plans{grid-template-columns:1fr}}
@media(max-width:620px){.title-segments,.analysis-strip,.asset-levels,.dna-grid,.parameter-grid,.shot-grid,.image-copy{grid-template-columns:1fr}}
/* V9.1 information hierarchy additions — preserves existing palette */
.evidence-fold{margin-top:18px;border-top:1px solid var(--line);padding-top:14px}.evidence-fold>summary{cursor:pointer;font-size:13px;font-weight:700;padding:10px 0;list-style:none}.evidence-fold>summary::-webkit-details-marker{display:none}.evidence-fold>summary:after{content:'+';float:right}.evidence-fold[open]>summary:after{content:'-'}
.insight-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin:18px 0}.insight-card{padding:18px;border:1px solid var(--line);background:var(--card,#fff)}.insight-card span,.consumer-thesis span,.continuity header span{display:block;font-size:11px;color:var(--muted);margin-bottom:7px}.insight-card strong,.consumer-thesis strong{display:block;font-size:18px;line-height:1.45}.insight-card p{margin:8px 0 0;font-size:13px;line-height:1.55;color:var(--muted)}
.consumer-thesis{padding:20px 0;border-bottom:1px solid var(--line);margin-bottom:14px}.continuity{margin:22px 0}.continuity header h3{margin:0 0 12px}.sku-status{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:16px}.sku-status article{padding:16px;border:1px solid var(--line);background:var(--card,#fff)}.sku-status span{display:block;font-size:11px;color:var(--muted);margin-bottom:6px}.sku-status strong{font-size:15px;line-height:1.4}.recommended-titles{margin-top:14px;padding:16px;border:1px solid var(--line)}.recommended-titles>span{font-size:11px;color:var(--muted)}.recommended-titles ul{margin:8px 0 0;padding-left:18px}.image-error{opacity:.18;min-height:120px;background:repeating-linear-gradient(45deg,transparent,transparent 8px,rgba(127,127,127,.08) 8px,rgba(127,127,127,.08) 16px)}
@media(max-width:900px){.insight-grid{grid-template-columns:1fr 1fr}.sku-status{grid-template-columns:1fr 1fr}}@media(max-width:560px){.insight-grid,.sku-status{grid-template-columns:1fr}}
/* =========================================================
V9.2.0 IMAGE-DRIVEN PROFESSIONAL REPORT
图片 > 专业意见 > 专业建议 > 证据
========================================================= */
:root{
--v92-ink:#17202a;--v92-text:#465364;--v92-muted:#8a95a3;--v92-line:#e8edf2;
--v92-soft:#f7f9fb;--v92-blue:#396bc8;--v92-blue-soft:#f2f6fd;--v92-white:#fff;
}
body{background:#fff;color:var(--v92-text);font-size:14px;line-height:1.55}
.wrap{width:min(1180px,calc(100% - 40px));margin:auto}
.v92-nav{height:52px;position:sticky;top:0;z-index:50;background:rgba(255,255,255,.96);backdrop-filter:blur(10px);border-bottom:1px solid var(--v92-line);box-shadow:none}
.v92-nav .wrap{height:100%;display:flex;align-items:center;justify-content:space-between;gap:18px}
.v92-nav .brand{font-size:13px;color:var(--v92-ink);font-weight:760;background:none;-webkit-background-clip:initial;background-clip:initial}
.v92-links{display:flex;gap:18px;overflow:auto}.v92-links a{font-size:12px;color:var(--v92-muted);text-decoration:none;white-space:nowrap;padding:0}.v92-links a:hover{background:none;color:var(--v92-blue)}
.v92-hero{padding:42px 0 30px;border-bottom:1px solid var(--v92-line);background:#fff;min-height:0}
.v92-hero:before{display:none}.v92-hero-grid{display:grid;grid-template-columns:1.08fr .92fr;gap:42px;align-items:center}
.v92-kicker{font-size:10px;font-weight:800;color:var(--v92-blue);letter-spacing:.08em;margin-bottom:8px}
.v92-hero h1{margin:0;color:var(--v92-ink);font-size:32px;line-height:1.2;letter-spacing:-.02em;font-weight:720}
.v92-meta{font-size:12px;color:var(--v92-muted);margin:10px 0 0}
.v92-cover{min-width:0}.v92-cover .visual{background:transparent;border:0;border-radius:0;box-shadow:none}.v92-cover img{display:block;width:100%;max-height:390px;object-fit:contain;background:transparent}
.cover-ratio{aspect-ratio:auto!important}.v92-cover-empty{min-height:280px;display:grid;place-items:center;background:var(--v92-soft);color:var(--v92-muted)}
.v92-opinion{margin:20px 0 0;padding:14px 16px;background:var(--v92-blue-soft);border-left:3px solid var(--v92-blue);border-radius:8px;display:grid;grid-template-columns:78px 1fr;gap:14px}
.v92-opinion span{font-size:10px;color:var(--v92-blue);font-weight:800}.v92-opinion strong{font-size:15px;line-height:1.55;color:var(--v92-ink);font-weight:700}
.v92-section{padding:42px 0;background:#fff}.v92-section+.v92-section{border-top:1px solid var(--v92-line)}
.v92-head{display:flex;align-items:baseline;gap:10px;margin-bottom:18px}.v92-head>span{font-size:10px;color:var(--v92-blue);font-weight:800}.v92-head h2{margin:0;font-size:21px;color:var(--v92-ink);line-height:1.25}.v92-head p{margin:0 0 0 auto;font-size:11px;color:var(--v92-muted)}
.v92-section>.wrap>.v92-opinion{margin-top:0;margin-bottom:18px}
.v92-summary3{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--v92-line);border-bottom:1px solid var(--v92-line)}
.v92-summary3 article{padding:15px 17px;border-right:1px solid var(--v92-line)}.v92-summary3 article:last-child{border-right:0}
.v92-summary3 span,.v92-title3 span{display:block;font-size:10px;color:var(--v92-muted);font-weight:700}.v92-summary3 b,.v92-title3 b{display:block;margin-top:5px;color:var(--v92-ink);font-size:13px;line-height:1.5}.v92-summary3 p{margin:4px 0 0;color:var(--v92-muted);font-size:11px}
.v92-title-current{padding:14px 16px;background:var(--v92-soft);border-radius:8px;color:var(--v92-ink);margin-bottom:13px}.v92-title-current small{display:block;font-size:10px;color:var(--v92-muted);margin-bottom:4px}
.v92-title3{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.v92-title3 article{padding:15px;border:1px solid var(--v92-line);border-radius:9px;background:#fff}
.v92-flow{display:flex;gap:7px;align-items:center;flex-wrap:wrap;margin:7px 0 17px}.v92-flow .node{font-size:11px;color:var(--v92-ink);font-weight:700;padding:6px 9px;background:var(--v92-soft);border-radius:99px}.v92-flow i{font-style:normal;color:#b7c0ca}
.v92-main-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:10px}.v92-main-card{min-width:0}.visual{margin:0;position:relative;background:var(--v92-soft);border-radius:9px;overflow:hidden}.visual img{width:100%;height:100%;display:block;object-fit:cover}.main-ratio{aspect-ratio:4/5}.wide-ratio{aspect-ratio:16/10}.square-ratio{aspect-ratio:1/1}.plan-ratio{aspect-ratio:4/3}
.image-fallback{display:none;width:100%;height:100%;min-height:120px;align-items:center;justify-content:center;color:var(--v92-muted);font-size:10px;text-align:center;padding:16px}.visual.failed .image-fallback{display:flex}.visual.failed{background:var(--v92-soft)}
.v92-main-copy{padding:8px 1px 0}.v92-main-copy span{display:block;color:var(--v92-ink);font-size:12px;font-weight:760}.v92-main-copy b{display:block;margin-top:2px;color:var(--v92-text);font-size:11px;line-height:1.45}.v92-main-copy p{margin:3px 0 0;color:var(--v92-muted);font-size:10px;line-height:1.45}
.v92-table-wrap{overflow:auto;margin-top:18px}.v92-matrix{width:100%;border-collapse:collapse;min-width:720px}.v92-matrix th,.v92-matrix td{padding:10px 11px;border-bottom:1px solid var(--v92-line);text-align:left;font-size:11px;vertical-align:top}.v92-matrix th{font-size:9px;color:var(--v92-muted);background:var(--v92-soft)}.v92-matrix .ok{color:#3c765e;font-weight:800}.v92-matrix .mid{color:#a06d2f;font-weight:800}.v92-matrix .none{color:#9aa3ae}
.v92-detail-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.v92-detail-card{border:1px solid var(--v92-line);border-radius:10px;overflow:hidden;background:#fff}.v92-detail-card>div:last-child{padding:12px}.v92-detail-card span{font-size:9px;color:var(--v92-blue);font-weight:800}.v92-detail-card b{display:block;margin-top:3px;color:var(--v92-ink);font-size:13px}.v92-detail-card p{margin:4px 0 0;color:var(--v92-muted);font-size:10.5px}.v92-evidence-note{margin-top:12px;font-size:10px;color:var(--v92-muted)}
.v92-sku-layout{display:grid;grid-template-columns:1.15fr .85fr;gap:24px;align-items:start}.v92-sku-mosaic{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}.v92-choice{border-left:1px solid var(--v92-line);padding-left:20px}.v92-choice-row{display:grid;grid-template-columns:36px 1fr;gap:10px;padding:9px 0;border-bottom:1px solid var(--v92-line)}.v92-choice-row span{font-size:9px;color:var(--v92-blue);font-weight:800}.v92-choice-row b{font-size:12px;color:var(--v92-ink)}.v92-choice-row small{grid-column:2;font-size:9px;color:var(--v92-muted)}.v92-choice-note{margin-top:13px;padding:12px;background:var(--v92-blue-soft);border-radius:8px;font-size:11px;color:#345077}.v92-status{display:block;margin-top:9px;font-size:9px;color:var(--v92-muted)}
.v92-review-layout{display:grid;grid-template-columns:1.15fr .85fr;gap:24px;align-items:start}.v92-buyer-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}.v92-insights{border-top:1px solid var(--v92-line)}.v92-insights article{padding:12px 0;border-bottom:1px solid var(--v92-line)}.v92-insights span{display:block;font-size:9px;color:var(--v92-muted);font-weight:750}.v92-insights b{display:block;margin-top:3px;color:var(--v92-ink);font-size:12.5px}.v92-insights p{margin:4px 0 0;color:var(--v92-muted);font-size:10.5px}
.v92-value-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}.v92-value-grid article{padding:15px;border-top:2px solid var(--v92-line);background:var(--v92-soft);border-radius:0 0 8px 8px}.v92-value-grid span{font-size:9px;color:var(--v92-muted);font-weight:800}.v92-value-grid b{display:block;margin-top:5px;color:var(--v92-ink);font-size:13px}.v92-value-grid p{margin:4px 0 0;color:var(--v92-muted);font-size:10.5px}
.v92-plan-stack{display:grid;gap:16px}.v92-plan{display:grid;grid-template-columns:190px 1fr 1fr;gap:20px;padding-top:16px;border-top:1px solid var(--v92-line)}.v92-plan:first-child{border-top:0;padding-top:0}.v92-plan-copy small{font-size:9px;color:var(--v92-blue);font-weight:800}.v92-plan-copy h3{margin:3px 0 7px;color:var(--v92-ink);font-size:17px}.v92-plan-copy p{margin:0;color:var(--v92-muted);font-size:11px}.v92-plan-points{display:grid;gap:8px}.v92-plan-points>div{padding:10px 12px;background:var(--v92-soft);border-radius:8px}.v92-plan-points span{display:block;font-size:9px;color:var(--v92-muted);font-weight:750}.v92-plan-points b{display:block;margin-top:3px;color:var(--v92-ink);font-size:11px}.v92-plan-placeholder{aspect-ratio:4/3;background:var(--v92-soft);display:grid;place-items:center;color:var(--v92-muted);font-size:10px;border-radius:9px}
.v92-empty{padding:18px;background:var(--v92-soft);color:var(--v92-muted);font-size:11px;border-radius:8px}.v92-footer{padding:28px 0 40px;border-top:1px solid var(--v92-line);font-size:10px;color:var(--v92-muted);background:#fff}
.image-error{display:none!important}
@media(max-width:900px){.v92-hero-grid{grid-template-columns:1fr}.v92-summary3,.v92-title3,.v92-detail-grid,.v92-value-grid{grid-template-columns:1fr}.v92-main-grid{grid-template-columns:repeat(2,1fr)}.v92-sku-layout,.v92-review-layout{grid-template-columns:1fr}.v92-choice{border-left:0;border-top:1px solid var(--v92-line);padding:16px 0 0}.v92-plan{grid-template-columns:1fr}.v92-head p{display:none}}
@media(max-width:560px){.wrap{width:min(100% - 24px,1180px)}.v92-hero h1{font-size:27px}.v92-opinion{grid-template-columns:1fr;gap:5px}.v92-main-grid{grid-template-columns:1fr 1fr}.v92-sku-mosaic,.v92-buyer-grid{grid-template-columns:repeat(2,1fr)}}
/* =========================================================
V9.2.5 OWNER + PROFESSIONAL USER FINAL REPORT
老板先决策;专业用户再看图片证据
图片完整展示,不裁切
========================================================= */
.visual img{object-fit:contain!important;background:var(--v92-soft)}
.v92-cover .visual,.v92-cover img{background:transparent!important}
.main-ratio{aspect-ratio:auto!important;height:310px}
.wide-ratio{aspect-ratio:auto!important;height:260px}
.square-ratio{aspect-ratio:auto!important;height:145px}
.plan-ratio{aspect-ratio:auto!important;height:190px}
.v92-buyer-grid .square-ratio{height:160px}
.v92-cover .cover-ratio{height:auto;min-height:300px;max-height:420px}
.v92-cover .cover-ratio img{height:auto!important;max-height:420px;width:100%;object-fit:contain!important}
.v924-decision3 article{padding:15px 16px}
.v924-decision3 b{font-size:14px}
.v924-decision3 p{font-size:10.5px;margin-top:4px}
.v924-title2{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px}
.v924-title2 article{padding:13px 14px;border:1px solid var(--v92-line);border-radius:8px;background:#fff}
.v924-title2 span{display:block;font-size:9px;color:var(--v92-muted);font-weight:760}
.v924-title2 b{display:block;margin-top:5px;color:var(--v92-ink);font-size:12.5px;line-height:1.45}
.v924-image-info{padding-top:8px}
.v924-row-title{display:flex;align-items:baseline;justify-content:space-between;gap:8px}
.v924-row-title strong{font-size:12px;color:var(--v92-ink)}
.v924-row-title span{font-size:8px;color:var(--v92-muted)}
.v924-chips{display:flex;gap:5px;flex-wrap:wrap;margin-top:6px}
.v924-chips i{font-style:normal;padding:3px 6px;border-radius:99px;background:var(--v92-soft);color:#586675;font-size:9px;line-height:1.35}
.v924-image-info p,.v924-detail-info p{margin:7px 0 0;font-size:10px;color:var(--v92-text);line-height:1.4}
.v924-image-info p b{font-size:8px;color:var(--v92-blue);margin-right:5px}
.v924-details{margin-top:15px;border-top:1px solid var(--v92-line)}
.v924-details summary{padding:12px 0;cursor:pointer;color:var(--v92-blue);font-size:11px;font-weight:700;list-style:none}
.v924-details summary::-webkit-details-marker{display:none}
.v924-details .v92-table-wrap{margin-top:0}
.v924-detail-info{padding:10px 11px!important}
.v924-detail-info strong{font-size:12px!important;color:var(--v92-ink)}
.v924-plan-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
.v924-plan-card{border:1px solid var(--v92-line);border-radius:9px;overflow:hidden;background:#fff}
.v924-plan-body{padding:12px}
.v924-plan-eyebrow{font-size:8px;color:var(--v92-blue);font-weight:800}
.v924-plan-body h3{margin:4px 0 8px;color:var(--v92-ink);font-size:15px;line-height:1.3}
.v924-plan-body>p{margin:0 0 8px;color:var(--v92-muted);font-size:10.5px;line-height:1.45}
.v924-plan-body dl{margin:0}
.v924-plan-body dl div{display:grid;grid-template-columns:36px 1fr;gap:6px;padding:7px 0;border-top:1px solid var(--v92-line)}
.v924-plan-body dt{font-size:8px;color:var(--v92-muted);font-weight:760}
.v924-plan-body dd{margin:0;font-size:10.5px;color:var(--v92-text);line-height:1.45}
/* V9.2.5 removes repeated explanatory subcopy from default reading layer. */
.v92-evidence-note{font-size:9px}
.v92-insights article p{display:none}
.v92-summary3 p:empty{display:none}
@media(max-width:900px){
.v924-title2,.v924-plan-grid{grid-template-columns:1fr}
.main-ratio{height:340px}.wide-ratio{height:300px}.plan-ratio{height:240px}
}
@media(max-width:560px){
.main-ratio{height:280px}.wide-ratio{height:240px}.square-ratio{height:150px}.plan-ratio{height:220px}
}
/* =========================================================
V9.2.5 BIG VISUAL / BIG TYPE FINAL REPORT
Big images tell facts; big type tells judgement; short lines tell actions.
========================================================= */
:root{--v925-ink:#141b24;--v925-text:#44505d;--v925-muted:#8a95a3;--v925-line:#e8edf2;--v925-soft:#f6f8fa;--v925-blue:#315fba;--v925-blue-soft:#eef4ff}
.wrap{width:min(1240px,calc(100% - 56px))}
.v92-nav .wrap{height:58px}.v92-nav .brand{font-size:15px}.v92-links{gap:22px}.v92-links a{font-size:13px}
.v92-hero{padding:58px 0 42px}.v92-hero-grid{grid-template-columns:.9fr 1.1fr;gap:54px;align-items:center}
.v92-kicker{font-size:12px;letter-spacing:.08em}.v92-hero h1{font-size:48px;line-height:1.12;letter-spacing:-.035em;margin:10px 0 14px}.v92-meta{font-size:14px}
.v925-hero-claim{margin-top:28px;font-size:26px;line-height:1.48;font-weight:800;color:var(--v925-ink);max-width:620px}
.v92-cover .cover-ratio{min-height:420px;max-height:530px}.v92-cover .cover-ratio img{max-height:530px!important}
.v92-section{padding:70px 0}.v92-head{margin-bottom:28px}.v92-head>span{font-size:12px}.v92-head h2{font-size:36px;line-height:1.2;letter-spacing:-.025em}.v92-head p{font-size:12px}
.v925-opinion{display:block!important;padding:0!important;background:transparent!important;border:0!important;margin:0 0 34px!important;max-width:940px}.v925-opinion strong{font-size:22px!important;line-height:1.55!important;color:var(--v925-ink)!important}
.v92-summary3{gap:28px;border:0}.v92-summary3 article{padding:20px 0 0;border-right:0!important;border-top:2px solid var(--v925-ink)}.v92-summary3 span{font-size:13px}.v92-summary3 b{font-size:28px;line-height:1.25;margin-top:9px}.v92-summary3 p{font-size:15px;line-height:1.5;margin-top:8px}
.v92-title-current{padding:18px 20px;border-radius:12px;font-size:17px;margin-bottom:18px}.v92-title-current small{font-size:12px}.v924-title2{gap:18px}.v924-title2 article{padding:22px;border-radius:12px}.v924-title2 span{font-size:12px}.v924-title2 b{font-size:22px;line-height:1.35;margin-top:7px}
.visual{background:var(--v925-soft);border-radius:14px}.visual img{object-fit:contain!important}.main-ratio,.wide-ratio,.square-ratio,.plan-ratio{aspect-ratio:auto}
.v92-main-grid{grid-template-columns:1fr 1fr;gap:28px}.v925-main-card:nth-child(5){grid-column:1/-1;max-width:620px}.v925-main-card .main-ratio{height:560px}.v925-main-card:nth-child(5) .main-ratio{height:620px}
.v925-image-info{padding:16px 2px 0}.v925-row-title{display:flex;justify-content:space-between;align-items:baseline;gap:12px}.v925-row-title h3{font-size:24px;margin:0;color:var(--v925-ink)}.v925-row-title span{font-size:12px;color:var(--v925-muted)}
.v925-signals{margin-top:9px;font-size:18px;line-height:1.5;font-weight:760;color:var(--v925-ink)}.v925-action{margin:9px 0 0!important;font-size:14px!important;line-height:1.45!important;color:var(--v925-blue)!important;font-weight:800!important}
.v924-details{margin-top:26px}.v924-details summary{font-size:14px;padding:16px 0}
.v925-detail-list{display:grid;gap:54px}.v925-detail-block{display:grid;grid-template-columns:1.2fr .8fr;gap:34px;align-items:center}.v925-detail-block:nth-child(even){grid-template-columns:.8fr 1.2fr}.v925-detail-block:nth-child(even) .visual{order:2}.v925-detail-block .wide-ratio{min-height:520px}.v925-detail-copy h3{font-size:30px;line-height:1.2;margin:0 0 14px;color:var(--v925-ink)}.v925-detail-copy .v925-signals{font-size:20px}.v925-detail-copy .v925-action{font-size:15px!important}.v92-evidence-note{font-size:12px;margin-top:24px}
.v92-sku-layout{grid-template-columns:1.3fr .7fr;gap:36px}.v92-sku-mosaic{grid-template-columns:repeat(2,1fr);gap:12px}.v92-sku-mosaic .square-ratio{height:300px}.v92-choice{padding-left:24px}.v92-choice-row{padding:17px 0;grid-template-columns:42px 1fr}.v92-choice-row span{font-size:11px}.v92-choice-row b{font-size:21px}.v92-choice-row small{font-size:13px;line-height:1.45}.v92-choice-note{margin-top:22px;padding:18px;border-radius:12px;font-size:17px;font-weight:760}.v92-status{font-size:11px;margin-top:12px}
.v92-review-layout{grid-template-columns:1.15fr .85fr;gap:38px}.v92-buyer-grid{grid-template-columns:1fr 1fr;gap:12px}.v92-buyer-grid .square-ratio{height:330px}.v92-insights article{padding:20px 0}.v92-insights span{font-size:12px}.v92-insights b{font-size:24px;line-height:1.35;margin-top:6px}
.v925-plan-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.v925-plan-card{border:1px solid var(--v925-line);border-radius:16px;overflow:hidden;background:#fff}.v925-plan-card .plan-ratio{height:300px}.v925-plan-body{padding:22px}.v925-plan-eyebrow{font-size:12px;color:var(--v925-blue);font-weight:800}.v925-plan-body h3{font-size:25px;line-height:1.25;margin:6px 0 14px;color:var(--v925-ink)}.v925-plan-body dl{margin:0}.v925-plan-body dl div{display:grid;grid-template-columns:52px 1fr;gap:10px;padding:12px 0;border-top:1px solid var(--v925-line)}.v925-plan-body dt{font-size:12px;color:var(--v925-muted);font-weight:800}.v925-plan-body dd{margin:0;font-size:15px;color:var(--v925-ink);line-height:1.5}
.v92-footer{font-size:12px;padding:34px 0 54px}
@media(max-width:900px){.wrap{width:min(100% - 28px,1240px)}.v92-hero-grid,.v92-sku-layout,.v92-review-layout,.v925-detail-block,.v925-detail-block:nth-child(even){grid-template-columns:1fr}.v92-hero h1{font-size:38px}.v925-hero-claim{font-size:21px}.v92-summary3,.v924-title2,.v925-plan-grid{grid-template-columns:1fr}.v92-main-grid{grid-template-columns:1fr}.v925-main-card:nth-child(5){grid-column:auto;max-width:none}.v925-detail-block:nth-child(even) .visual{order:0}.v92-head h2{font-size:30px}.v92-head p{display:none}.v92-sku-mosaic{grid-template-columns:1fr 1fr}.v92-buyer-grid{grid-template-columns:1fr 1fr}}
@media(max-width:560px){.v92-hero h1{font-size:32px}.v925-hero-claim{font-size:19px}.v925-main-card .main-ratio,.v925-main-card:nth-child(5) .main-ratio{height:440px}.v925-detail-block .wide-ratio{min-height:400px}.v92-sku-mosaic .square-ratio,.v92-buyer-grid .square-ratio{height:220px}}
/* JSON report view */
.report-app .wrap{max-width:1180px;margin:0 auto;padding:0 24px}
.report-section{padding:42px 0;border-bottom:1px solid #e8ece7}
.report-section h2{margin:0 0 22px;font-size:28px}
.report-app .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px}
.report-app .card{background:#fff;border:1px solid #e1e7e0;border-radius:14px;padding:18px;box-shadow:0 5px 18px #172b1a0b}
.report-app .card h3{margin:0 0 10px;font-size:17px}.report-app .card p{margin:7px 0;line-height:1.6}
.report-app .gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-bottom:18px}
.report-app figure{margin:0;background:#f4f7f3;border-radius:12px;overflow:hidden}.report-app figure img{width:100%;height:220px;object-fit:contain;display:block}.report-app figcaption{padding:7px 10px;font-size:12px;color:#647064}
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>商品开品分析报告</title><link rel="stylesheet" href="/report.css"></head>
<body><main id="app" class="report-app"><p>正在加载报告数据…</p></main>
<script src="/report.js"></script></body></html>
+26
View File
@@ -0,0 +1,26 @@
/* 独立报告视图:页面只负责渲染 JSON,不在 Python 中拼接 HTML。 */
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const arr = v => Array.isArray(v) ? v : [];
const text = (v, keys=['value','label','headline','note','insight','action','name']) => {
if (Array.isArray(v)) return v.map(x => text(x, keys)).filter(Boolean).join(' · ');
if (typeof v === 'string' || typeof v === 'number') return String(v);
if (v && typeof v === 'object') for (const k of keys) if (v[k] != null && v[k] !== '') return text(v[k], keys);
return '';
};
function cards(items, fields=['headline','note']) {
return arr(items).map(x => `<article class="card"><h3>${esc(text(x,['label','name','type']))}</h3>${fields.map(k=>text(x,[k])).filter(Boolean).map(v=>`<p>${esc(v)}</p>`).join('')}</article>`).join('');
}
function images(data, group, limit=8) {
const list = arr(data.evidenceLedger).filter(e => e?.type === 'image' && e?.meta?.group === group).slice(0,limit);
return list.map(e => `<figure><img loading="lazy" src="${esc(e.meta.localUrl || e.value)}" alt="商品${esc(group)}"><figcaption>${esc(e.id)}</figcaption></figure>`).join('') || '<p>未采集</p>';
}
function section(id, title, body) { return `<section id="${id}" class="report-section"><div class="wrap"><h2>${esc(title)}</h2>${body}</div></section>`; }
async function main() {
const q = new URLSearchParams(location.search); const source = window.REPORT_DATA || q.get('data');
if (!source) throw new Error('缺少 data 参数');
const data = await fetch(source).then(r => r.json()); const exp = data.experienceSolution || {}; const raw = data.facts || {}; const p = raw.product || {}; const s = raw.sales || {};
const summary = exp.reportSummary || {}; document.title = summary.title || p.title || '商品开品分析报告';
const owner = exp.ownerOverview?.cards || []; const plans = exp.newProductPlans?.plans || []; const title = exp.titleAnalysis || {}; const visual = exp.visualCommerce || {}; const detail = exp.detailCommerce || {};
document.getElementById('app').innerHTML = `<nav class="v92-nav"><div class="wrap"><div class="brand">三笙 · 商品开品分析</div><a href="#decision">老板速览</a> <a href="#plan">新品方向</a> <a href="#main">主图</a> <a href="#review">评价</a></div></nav><header class="v92-hero"><div class="wrap"><div class="v92-kicker">PRODUCT DECISION REPORT · JSON VIEW</div><h1>${esc(summary.title || p.title || '商品分析报告')}</h1><p>${esc(summary.verdict || '')}</p><small>价格 ${esc(s.currentPrice || '未采集')} · 评论 ${esc(data.baseline?.reviewCount || 0)}</small></div></header>${section('decision','老板速览',`<div class="grid">${cards(owner)}</div>`)}${section('plan','下一款方向',`<div class="grid">${cards(plans,['sourceType','whyThisPlan','productAction','pageAction'])}</div>`)}${section('title','标题表达',`<p>${esc(title.professionalOpinion || '')}</p>`)}${section('main','主图在卖什么',`<div class="gallery">${images(data,'main')}</div><div class="grid">${cards(visual.items,['imageRole','visualSignals','nextAction'])}</div>`)}${section('detail','详情页证明什么',`<div class="gallery">${images(data,'detail')}</div><div class="grid">${cards(detail.contentGroups,['type','visualSignals','nextAction'])}</div>`)}${section('sku','SKU怎么选',`<p>${esc(exp.skuAnalysis?.professionalOpinion || '')}</p>${cards(exp.skuAnalysis?.selectionDimensions,['name','values','evidenceIds'])}`)}${section('review','消费者实际感受到什么',`<div class="gallery">${images(data,'buyerShow',6)}</div>${cards(exp.customerExperience?.consumerInsights?.experienceSignals,['type','insight'])}`)}<footer class="v92-footer"><div class="wrap">数据来自本次采集证据;页面由独立 HTML + JSON 渲染</div></footer>`;
}
main().catch(e => { document.getElementById('app').innerHTML = `<p class="error">报告加载失败:${esc(e.message)}</p>`; });
+531
View File
@@ -0,0 +1,531 @@
import json, os, threading, time, uuid, traceback, html, base64, csv, io, zipfile, re, mimetypes, hashlib, urllib.request, urllib.error
import xml.etree.ElementTree as ET
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, unquote_to_bytes
from pathlib import Path
from analysis import analyze, normalize
from renderer import render
from ai_client import configured, load_config, probe_model_api
from prompt_templates import TEMPLATES, PRODUCT_VARIABLES
ROOT=Path(__file__).resolve().parent
PROMPT_CONFIG=ROOT/'prompts.json'
REPORTS=ROOT/'reports'; REPORTS.mkdir(exist_ok=True)
MONITOR_DIR=ROOT/'monitor_data'; MONITOR_DIR.mkdir(exist_ok=True)
PORT=int(os.getenv('TMALL_AI_PORT','17962'))
SERVER_VERSION='9.2.5'
MODEL_PROBE={'ok':False,'stage':'startup','error':'尚未检测'}
MODEL_PROBE_AT=0
TASKS={}
LOCK=threading.Lock()
IMAGE_ASSET_ROOT=REPORTS/'assets'; IMAGE_ASSET_ROOT.mkdir(exist_ok=True)
def _asset_ext(url, content_type=''):
c=(content_type or '').split(';',1)[0].strip().lower()
mapping={'image/jpeg':'.jpg','image/png':'.png','image/webp':'.webp','image/gif':'.gif','image/avif':'.avif'}
if c in mapping:return mapping[c]
path=urlparse(str(url or '')).path.lower()
for ext in ('.webp','.jpg','.jpeg','.png','.gif','.avif'):
if ext in path:return '.jpg' if ext=='.jpeg' else ext
return '.img'
def _normalize_asset_url(url):
url=str(url or '').strip().replace('&amp;','&')
if url.startswith('//'):url='https:'+url
return url
def _looks_like_image(data, content_type=''):
if not data:return False
c=(content_type or '').split(';',1)[0].strip().lower()
if c.startswith('image/'):return True
sig=data[:16]
return (sig.startswith(b'\xff\xd8\xff') or sig.startswith(b'\x89PNG\r\n\x1a\n') or sig[:6] in (b'GIF87a',b'GIF89a') or (len(sig)>=12 and sig[:4]==b'RIFF' and sig[8:12]==b'WEBP') or b'ftypavif' in data[:32])
def _download_image(url, timeout=18):
"""Download a commerce image with several anti-hotlink compatible header strategies."""
url=_normalize_asset_url(url)
strategies=[
{'Referer':'https://detail.tmall.com/','Origin':'https://detail.tmall.com'},
{'Referer':'https://item.taobao.com/','Origin':'https://item.taobao.com'},
{'Referer':'https://www.taobao.com/'},
{},
]
last=None
for extra in strategies:
try:
headers={
'User-Agent':'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/151 Safari/537.36',
'Accept':'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
'Accept-Language':'zh-CN,zh;q=0.9,en;q=0.8',
'Cache-Control':'no-cache',
}
headers.update(extra)
req=urllib.request.Request(url,headers=headers)
with urllib.request.urlopen(req,timeout=timeout) as r:
data=r.read(16*1024*1024+1)
if len(data)>16*1024*1024:raise ValueError('image too large')
ctype=r.headers.get('Content-Type','')
if not _looks_like_image(data,ctype):raise ValueError('not image response')
return data,ctype
except Exception as ex:last=ex
raise last or RuntimeError('image download failed')
def cache_report_images(result, task_id):
"""Persist report images under /reports/assets. Keep the source URL as a browser fallback."""
root=IMAGE_ASSET_ROOT/task_id; root.mkdir(parents=True,exist_ok=True)
ok=failed=skipped=0
for e in result.get('evidenceLedger') or []:
if not isinstance(e,dict) or e.get('type')!='image':continue
url=_normalize_asset_url(e.get('value'))
meta=e.setdefault('meta',{})
if not url:
skipped+=1;continue
# Keep source URL even when caching fails so the renderer can still try the browser.
meta['sourceUrl']=url
group=re.sub(r'[^a-zA-Z0-9_-]+','_',str(meta.get('group') or 'other'))
eid=re.sub(r'[^a-zA-Z0-9_-]+','_',str(e.get('id') or hashlib.sha1(url.encode()).hexdigest()[:12]))
group_dir=root/group; group_dir.mkdir(parents=True,exist_ok=True)
try:
if url.startswith('data:image/'):
head,payload=url.split(',',1)
data=base64.b64decode(payload) if ';base64' in head else unquote_to_bytes(payload)
ctype=head[5:].split(';',1)[0]
elif url.startswith(('http://','https://')):
data,ctype=_download_image(url)
else:
raise ValueError('unsupported image url')
ext=_asset_ext(url,ctype); target=group_dir/(eid+ext); target.write_bytes(data)
# Browser URL is absolute-from-server-root and therefore works from every report route.
meta['localUrl']=f'/reports/assets/{task_id}/{group}/{target.name}'
meta['cacheStatus']='success'; meta.pop('cacheError',None); ok+=1
except Exception as ex:
meta.pop('localUrl',None); meta['cacheStatus']='failed'; meta['cacheError']=str(ex)[:220]; failed+=1
result.setdefault('meta',{})['imageCache']={'success':ok,'failed':failed,'skipped':skipped,'mode':'local-first-multi-retry-remote-fallback'}
return result
def jdump(o): return json.dumps(o,ensure_ascii=False).encode('utf-8')
def _norm_key(k): return re.sub(r'\s+','',str(k or '')).lower()
MONITOR_ALIASES={
'date':['日期','采集日期','监测日期','统计日期','date','day','capturedate','capturedat','capturedAt'],
'itemId':['商品id','itemid','item_id','id','商品链接','链接','url','商品url'],
'sales':['销量展示值','销量展示','销量','销售量','salesdisplay','sales','sold','销量档位','已售'],
'rank':['榜单排名','排名','rank','ranking','榜单','类目排名'],
'price':['价格','当前价','当前价格','到手价','price','currentprice'],
'promotion':['活动','促销','promotion','promotions','活动信息','优惠'],
'title':['标题','商品标题','title','name','商品名称']
}
def _pick(row,name):
m={_norm_key(k):v for k,v in (row or {}).items()}
for a in MONITOR_ALIASES[name]:
if _norm_key(a) in m:return m[_norm_key(a)]
return ''
def _xlsx_rows(data):
# 标准 xlsx 第一张工作表;纯标准库解析,用户本机无需安装 openpyxl。
with zipfile.ZipFile(io.BytesIO(data)) as z:
shared=[]
if 'xl/sharedStrings.xml' in z.namelist():
root=ET.fromstring(z.read('xl/sharedStrings.xml'))
ns={'a':'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
for si in root.findall('a:si',ns): shared.append(''.join(t.text or '' for t in si.findall('.//a:t',ns)))
wb=ET.fromstring(z.read('xl/workbook.xml'))
rel=ET.fromstring(z.read('xl/_rels/workbook.xml.rels'))
relmap={x.attrib.get('Id'):x.attrib.get('Target') for x in rel}
ns={'a':'http://schemas.openxmlformats.org/spreadsheetml/2006/main','r':'http://schemas.openxmlformats.org/officeDocument/2006/relationships'}
sh=wb.find('a:sheets/a:sheet',ns); rid=sh.attrib.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id')
target=relmap.get(rid,'worksheets/sheet1.xml').lstrip('/')
if not target.startswith('xl/'): target='xl/'+target
root=ET.fromstring(z.read(target))
rows=[]
for rr in root.findall('.//a:sheetData/a:row',ns):
vals=[]
for c in rr.findall('a:c',ns):
t=c.attrib.get('t'); v=c.find('a:v',ns); val='' if v is None else (v.text or '')
if t=='s' and val!='':
try: val=shared[int(val)]
except: pass
elif t=='inlineStr':
val=''.join(x.text or '' for x in c.findall('.//a:t',ns))
vals.append(val)
rows.append(vals)
if not rows:return []
head=[str(x).strip() for x in rows[0]]
return [{head[i]:(r[i] if i<len(r) else '') for i in range(len(head))} for r in rows[1:] if any(str(x).strip() for x in r)]
def parse_monitor_file(filename,data):
ext=Path(filename).suffix.lower()
if ext=='.json':
obj=json.loads(data.decode('utf-8-sig')); rows=obj if isinstance(obj,list) else obj.get('data',[]) if isinstance(obj,dict) else []
elif ext=='.csv':
text=data.decode('utf-8-sig',errors='replace'); rows=list(csv.DictReader(io.StringIO(text)))
elif ext in ('.xlsx','.xls'):
if ext=='.xls': raise ValueError('旧版 .xls 暂不支持,请另存为 .xlsx 或 CSV')
rows=_xlsx_rows(data)
else: raise ValueError('仅支持 .xlsx / .csv / .json')
return rows
def _norm_item_id(v):
s=str(v or '').strip()
if not s:return ''
# URL / 普通数字 / Excel 科学计数法都尽量恢复为商品ID
m=re.search(r'[?&]id=(\d+)',s)
if m:return m.group(1)
if re.fullmatch(r'\d+(?:\.0+)?',s): return s.split('.')[0]
if re.fullmatch(r'\d+(?:\.\d+)?[eE][+-]?\d+',s):
try:return str(int(float(s)))
except:return s
m=re.search(r'(?<!\d)(\d{8,})(?!\d)',s)
return m.group(1) if m else s
def _norm_monitor_date(v):
s=str(v or '').strip()
if not s:return ''
# Excel 日期序列号
try:
f=float(s)
if 20000 <= f <= 80000:
import datetime
d=datetime.datetime(1899,12,30)+datetime.timedelta(days=f)
return d.strftime('%Y-%m-%d')
except: pass
s=s.replace('/','-').replace('.','-')
m=re.match(r'(\d{4})-(\d{1,2})-(\d{1,2})',s)
if m:return f"{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}"
return s
def normalize_monitor_rows(rows,item_id,force_bind=False):
out=[]
current=_norm_item_id(item_id)
for r in rows:
rid=_norm_item_id(_pick(r,'itemId'))
if rid and current and rid!=current and not force_bind: continue
date=_norm_monitor_date(_pick(r,'date'))
sales=str(_pick(r,'sales') or '').strip()
if not date or not sales: continue
out.append({
'date':date,
'itemId':current or rid,
'salesDisplay':sales,
'ranking':str(_pick(r,'rank') or '').strip(),
'price':str(_pick(r,'price') or '').strip(),
'promotion':str(_pick(r,'promotion') or '').strip(),
'title':str(_pick(r,'title') or '').strip()
})
by={}
for x in out: by[x['date']]=x
return sorted(by.values(),key=lambda x:x['date'])[-7:]
def monitor_path(item_id): return MONITOR_DIR/(re.sub(r'\D','',str(item_id)) or 'unknown')
def set_task(tid, **kw):
with LOCK: TASKS.setdefault(tid,{}).update(kw)
def task_evidence_summary(raw):
clean=normalize(raw);images=clean.get('images') or {}; collection=clean.get('collection') or {}
groups=(('main','商品主图'),('detail','详情图'),('sku','SKU 图'),('buyerShow','评论原图'))
preview=[];seen=set()
for key,label in groups:
for url in (images.get(key) or [])[:4]:
if not isinstance(url,str) or not url.startswith(('http://','https://','data:image/')) or url in seen:continue
seen.add(url);preview.append({'url':url,'group':key,'label':label})
if len(preview)>=8:break
if len(preview)>=8:break
return {
'counts':{
'parameters':len(clean.get('attributes') or []),'reviews':len(clean.get('reviews') or []),
'questions':len(clean.get('questions') or []),'main':len(images.get('main') or []),
'detail':len(images.get('detail') or []),'sku':len(images.get('sku') or []),
'buyerShow':len(images.get('buyerShow') or []),
},
'publicReviewCount':collection.get('publicReviewCount') or '',
'reviewComplete':bool(collection.get('reviewCollectionComplete')),
'previewImages':preview,
}
def worker(tid, raw):
try:
evidence_summary=task_evidence_summary(raw);counts=evidence_summary['counts']
set_task(tid,status='analyzing',progress=28,evidenceSummary=evidence_summary,steps={'collect':'done','evidence':'waiting','planner':'waiting','product':'waiting','fashion':'waiting','visual':'not_collected' if not counts['main'] else 'waiting','merchandising':'waiting','detail':'not_collected' if not counts['detail'] else 'waiting','review':'waiting','qa':'waiting','competition':'waiting','strategy':'waiting','plans':'waiting','editor':'waiting','report':'waiting'})
def progress(step,state,pct):
with LOCK:
t=TASKS[tid]; t['progress']=pct
if step=='visual' and not counts['main']:t['steps'][step]='not_collected'
elif step=='detail' and not counts['detail']:t['steps'][step]='not_collected'
else:t['steps'][step]=state
result=analyze(raw,progress)
if not (result.get('meta') or {}).get('reportReady'):
(REPORTS/f'{tid}.failed.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),'utf-8')
raise RuntimeError((result.get('meta') or {}).get('reportBlockedReason') or '模型未生成可输出报告,本次不使用兜底内容。')
set_task(tid,status='rendering',progress=95)
TASKS[tid]['steps']['report']='processing'
result=cache_report_images(result,tid)
(REPORTS/f'{tid}.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),'utf-8')
# 报告页面是静态 HTML;数据单独由 JSON 接口提供,便于部署和独立迭代 UI。
set_task(tid,status='complete',progress=100,reportUrl=f'/report.html?data=/reports/{tid}.json',analysisUrl=f'/reports/{tid}.json')
TASKS[tid]['steps']['report']='done'
except Exception as e:
with LOCK:
task=TASKS.setdefault(tid,{})
for key,state in (task.get('steps') or {}).items():
if str(state).startswith('processing'):task['steps'][key]='failed'
set_task(tid,status='error',error=str(e),trace=traceback.format_exc(),progress=100)
def task_page(tid):
return f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>行业平台报告生成中</title><link rel="stylesheet" href="/report.css"></head><body class="loading-page"><main class="loading-shell"><header class="loading-head"><div><div class="eyebrow">SANXIAN · PRODUCT INTELLIGENCE</div><h1 id="title">正在生成商品产品分析报告</h1><p id="desc">按真实采集证据完成核验、评论聚合、主图分析、详情参数解读、活动分析、产品定义、推荐方案与落地方式。</p></div><strong id="percent">8%</strong></header><section class="loading-evidence"><div class="loading-section-title"><span>本次证据</span><small id="reviewState"></small></div><div id="evidenceStats" class="loading-stats"></div><div id="imagePreview" class="loading-images"></div></section><section class="loading-work"><div class="loading-section-title"><span>生成进度</span><small>缺失资料会明确标记,不会补写</small></div><div id="steps" class="loading-steps"></div></section><div class="topic-bar"><i id="bar" style="width:8%"></i></div><p id="err" class="error"></p></main><script>window.TASK_ID={json.dumps(tid)}</script><script src="/task.js"></script></body></html>'''
TASK_JS='''
const labels={collect:'商品页面采集',evidence:'证据清洗与建库',planner:'报告结构编排',product:'商品与参数核验',fashion:'设计资产梳理',visual:'商品主图核验',merchandising:'标题与活动核验',detail:'商品详情图核验',review:'评论体验聚合',qa:'购买问答整理',competition:'竞品数据边界',strategy:'产品结论生成',plans:'开品建议生成',editor:'最终报告汇总与校验',report:'图文报告渲染'};
const stateText={done:'已完成',processing:'处理中',waiting:'待处理',not_collected:'未采到',failed:'失败'};
const statLabels={parameters:'有效参数',reviews:'有效评论',questions:'去重问答',main:'商品主图',detail:'详情图',sku:'SKU 图',buyerShow:'评论原图'};
function fillEvidence(summary){
const counts=summary?.counts||{}; const stats=document.getElementById('evidenceStats'); stats.replaceChildren();
Object.entries(statLabels).forEach(([key,label])=>{const card=document.createElement('div');const s=document.createElement('span');s.textContent=label;const b=document.createElement('b');b.textContent=String(counts[key]||0);card.append(s,b);stats.append(card)});
const state=document.getElementById('reviewState'); const publicCount=summary?.publicReviewCount; state.textContent=(publicCount?`已采 ${counts.reviews||0} / 页面公开 ${publicCount} · `:`已采 ${counts.reviews||0} 条 · `)+(summary?.reviewComplete?'评论采集已确认完整':'评论采集未确认完整');
const preview=document.getElementById('imagePreview'); preview.replaceChildren();
(summary?.previewImages||[]).forEach((item,i)=>{const fig=document.createElement('figure');const img=document.createElement('img');img.src=item.url;img.alt=item.label+' '+(i+1);img.referrerPolicy='no-referrer';const cap=document.createElement('figcaption');cap.textContent=item.label;fig.append(img,cap);preview.append(fig)});
preview.hidden=!preview.children.length;
}
function fillSteps(steps){const box=document.getElementById('steps');box.replaceChildren();Object.entries(steps||{}).forEach(([key,state])=>{const row=document.createElement('div');row.className='loading-step '+String(state).split(' ')[0];const mark=document.createElement('i');const name=document.createElement('span');name.textContent=labels[key]||key;const value=document.createElement('b');value.textContent=String(state).startsWith('processing')?'处理中':(stateText[state]||state);row.append(mark,name,value);box.append(row)})}
async function tick(){
const r=await fetch('/api/task/'+encodeURIComponent(window.TASK_ID)); const d=await r.json();
document.getElementById('bar').style.width=(d.progress||0)+'%';
document.getElementById('percent').textContent=(d.progress||0)+'%'; fillEvidence(d.evidenceSummary); fillSteps(d.steps);
if(d.status==='complete'){ location.href=d.reportUrl; return; }
if(d.status==='error'){ document.getElementById('title').textContent='本次未生成报告'; document.getElementById('desc').textContent='模型结论未达到完整性与证据引用要求,不显示兜底内容。'; document.getElementById('err').textContent=d.error||'未生成可输出结论'; return; }
setTimeout(tick,900);
} tick();
'''
def save_config(data):
p=ROOT/'config.json'
old={}
if p.exists():
try: old=json.loads(p.read_text('utf-8'))
except Exception: old={}
key=str(data.get('api_key','')).strip()
if not key: key=str(old.get('api_key','')).strip()
cfg={
'api_base':str(data.get('api_base') or old.get('api_base') or 'https://api.bananarouter.com/v1').strip().rstrip('/'),
'api_key':key,
'model':str(data.get('model') or old.get('model') or 'gpt-5.5').strip(),
'chat_path':'/chat/completions','models_path':'/models','temperature':0.2,'timeout':120,'retries':2,
'ssl_verify':True,'ca_bundle':'','extra_headers':{}
}
tmp=p.with_suffix('.json.tmp')
tmp.write_text(json.dumps(cfg,ensure_ascii=False,indent=2),'utf-8')
tmp.replace(p)
return cfg
def control_page():
global MODEL_PROBE, MODEL_PROBE_AT
c=load_config(); is_cfg=configured()
if is_cfg and (time.time()-MODEL_PROBE_AT>30):
MODEL_PROBE=probe_model_api(timeout=12); MODEL_PROBE_AT=time.time()
api_ok=bool(MODEL_PROBE.get('ok'))
base=html.escape(str(c.get('api_base') or 'https://api.bananarouter.com/v1'))
model=html.escape(str(c.get('model') or 'gpt-5.5'))
cfg_err=html.escape(str(c.get('_config_error') or ''))
probe=html.escape(str(MODEL_PROBE.get('error') or ''))
key_state='已保存' if str(c.get('api_key') or '').strip() and not str(c.get('api_key')).startswith('YOUR_') else '未填写'
return f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>三笙 · 电商商品分析闭环 V{SERVER_VERSION}</title><style>
*{{box-sizing:border-box}}body{{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;background:#f5faf9;color:#10191d;margin:0}}.wrap{{max-width:900px;margin:54px auto;padding:0 24px}}.hero{{background:#071114;color:#fff;padding:34px;border-radius:22px;border-top:5px solid #00cdb0}}h1{{font-size:38px;margin:0 0 10px}}.sub{{color:#b9d0d4}}.card{{margin-top:18px;background:#fff;border:1px solid #dbe8e8;border-radius:18px;padding:24px}}.status{{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}}.pill{{padding:14px;border-radius:12px;background:#eef9f7;font-weight:700}}.ok{{color:#008c78}}.bad{{color:#c54552}}label{{display:block;font-size:13px;font-weight:700;margin:16px 0 7px}}input{{width:100%;padding:13px 14px;border:1px solid #bcd8d6;border-radius:10px;font-size:15px}}button{{margin-top:18px;border:0;border-radius:10px;padding:13px 18px;background:linear-gradient(110deg,#00cdb0,#008fd8);color:white;font-weight:800;font-size:15px;cursor:pointer}}button.secondary{{background:#071114;margin-left:8px}}#msg{{margin-top:12px;white-space:pre-wrap}}code{{background:#eaf7f5;padding:3px 6px;border-radius:5px}}@media(max-width:700px){{.status{{grid-template-columns:1fr}}}}
</style></head><body><div class="wrap"><section class="hero"><div style="font-size:12px;letter-spacing:.16em;color:#12d8bb;font-weight:800">ONE-CLICK LOCAL SERVICE</div><h1>三笙 · 电商商品分析引擎 V{SERVER_VERSION}</h1><div class="sub">单商品手动深采;类目不限,参数与SKU动态识别,评论可持续采集,也可随时基于当前数据开始分析。</div></section>
<section class="card"><div class="status"><div class="pill">本地服务<br><span class="ok">运行正常 · V{SERVER_VERSION}</span></div><div class="pill">模型配置<br><span class="{'ok' if is_cfg else 'bad'}">{'已配置 '+model if is_cfg else '待配置'}</span></div><div class="pill">API连接<br><span class="{'ok' if api_ok else 'bad'}">{'连通 ✓' if api_ok else '待检测/失败'}</span></div></div></section>
<section class="card"><h2 style="margin-top:0">首次只需要填一次 API Key</h2><p style="color:#667067">默认已为 BananaRouter + gpt-5.5 配好。API Key 只保存在本机 <code>server/config.json</code>,不会写入 Chrome 扩展。</p>
<label>API Base</label><input id="base" value="{base}"><label>API Key({key_state})</label><input id="key" type="password" placeholder="粘贴新的 sk-...;已保存时可留空"><label>模型</label><input id="model" value="{model}">
<button onclick="save()">保存并测试连接</button><button class="secondary" onclick="test()">仅重新测试</button><div id="msg">{'配置错误:'+cfg_err if cfg_err else ('API错误:'+probe if (is_cfg and not api_ok and probe) else '')}</div></section>
<section class="card"><h2 style="margin-top:0">下一步</h2><p>Chrome 只需加载一次 <code>extension</code> 文件夹。之后每次使用:双击启动 → 打开已登录的天猫商品页 → 可选导入监测数据 → 点击扩展“开始深度采集”。</p></section></div>
<script>
async function save(){{const msg=document.getElementById('msg');msg.textContent='正在保存并测试…';const r=await fetch('/api/config',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{api_base:base.value,api_key:key.value,model:model.value}})}});const d=await r.json();msg.textContent=d.ok?'✓ 配置已保存,API连接正常。现在可以去天猫商品页运行扩展。':'✕ '+(d.error||'配置失败');if(d.ok)setTimeout(()=>location.reload(),600)}}
async function test(){{const msg=document.getElementById('msg');msg.textContent='正在测试…';const r=await fetch('/api/model-test');const d=await r.json();msg.textContent=d.ok?'✓ API连接正常,模型 '+(d.model||'')+' 可用。':'✕ '+(d.error||'连接失败')}}
</script></body></html>'''
def prompt_page():
saved={}
if PROMPT_CONFIG.exists():
try: saved=json.loads(PROMPT_CONFIG.read_text('utf-8'))
except Exception: saved={}
items={k:{'name':v.name,'body':saved.get(k,{}).get('body',v.body),'variables':list(v.variables)} for k,v in TEMPLATES.items()}
return '<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>提示词配置</title><style>body{font-family:-apple-system,BlinkMacSystemFont,PingFang SC,sans-serif;background:#f5faf9;margin:0}.wrap{max-width:1100px;margin:30px auto;padding:0 20px}.card{background:#fff;border:1px solid #dbe8e8;border-radius:16px;padding:22px;margin:16px 0}textarea{width:100%;min-height:260px;font:14px monospace;padding:12px;border:1px solid #bcd8d6;border-radius:10px}button{background:#008f7a;color:#fff;border:0;border-radius:9px;padding:12px 18px;font-weight:700;cursor:pointer}.vars{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px}.var{background:#eef9f7;padding:10px;border-radius:8px;font-size:13px}</style><div class="wrap"><h1>提示词可视化配置</h1><p>编辑后保存,下一次分析任务生效。</p><section class="card"><h2>商品变量说明</h2><div class="vars">'+Object.entries(PRODUCT_VARIABLES).map(([k,v])=>`<div class="var"><b>${html.escape(k)}</b><br>${html.escape(v)}</div>`).join('')+'</div></section>'+Object.entries(items).map(([k,v])=>`<section class="card"><h2>${html.escape(k)}</h2><p>变量:${html.escape(v.variables.join('、')||'自定义')}</p><textarea data-key="${html.escape(k)}">${html.escape(v.body)}</textarea></section>`).join('')+'<button onclick="save()">保存全部模板</button><span id="msg" style="margin-left:12px"></span></div><script>async function save(){const templates={};document.querySelectorAll("textarea[data-key]").forEach(x=>templates[x.dataset.key]={body:x.value});const r=await fetch("/api/prompts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({templates})});const d=await r.json();document.getElementById("msg").textContent=d.ok?"已保存":"保存失败:"+(d.error||"")}</script></html>'
class H(BaseHTTPRequestHandler):
def log_message(self, fmt, *args): print('[HTTP]',fmt%args)
def cors(self):
self.send_header('Access-Control-Allow-Origin','*');self.send_header('Access-Control-Allow-Headers','Content-Type');self.send_header('Access-Control-Allow-Methods','GET,POST,OPTIONS')
def send_json(self,obj,code=200):
b=jdump(obj);self.send_response(code);self.cors();self.send_header('Content-Type','application/json; charset=utf-8');self.send_header('Content-Length',str(len(b)));self.end_headers();self.wfile.write(b)
def send_file(self,p,ctype):
p=Path(p)
if not p.exists(): self.send_error(404);return
b=p.read_bytes();self.send_response(200);self.cors();self.send_header('Content-Type',ctype);self.send_header('Content-Length',str(len(b)));self.end_headers();self.wfile.write(b)
def do_OPTIONS(self): self.send_response(204);self.cors();self.end_headers()
def do_POST(self):
global MODEL_PROBE, MODEL_PROBE_AT
if self.path=='/api/prompts':
try:
n=int(self.headers.get('Content-Length','0')); data=json.loads(self.rfile.read(n).decode('utf-8') or '{}')
templates=data.get('templates') if isinstance(data.get('templates'),dict) else {}
clean={k:{'body':str(v.get('body',''))[:100000]} for k,v in templates.items() if k in TEMPLATES and isinstance(v,dict)}
PROMPT_CONFIG.write_text(json.dumps(clean,ensure_ascii=False,indent=2),'utf-8'); self.send_json({'ok':True})
except Exception as e: self.send_json({'ok':False,'error':str(e)},400)
return
if self.path=='/api/monitor-import':
try:
n=int(self.headers.get('Content-Length','0'))
data=json.loads(self.rfile.read(n).decode('utf-8') or '{}')
item_id=_norm_item_id(data.get('itemId'))
filename=str(data.get('filename') or '')
force_bind=bool(data.get('forceBind'))
if not item_id:
self.send_json({'ok':False,'error':'缺少当前商品 itemId','code':'MISSING_ITEM_ID'},400); return
blob=base64.b64decode(data.get('dataBase64') or '')
parsed=parse_monitor_file(filename,blob)
file_ids=sorted({ _norm_item_id(_pick(r,'itemId')) for r in parsed if _norm_item_id(_pick(r,'itemId')) })
if file_ids and item_id not in file_ids and not force_bind:
self.send_json({
'ok':False,
'code':'ITEM_ID_MISMATCH',
'error':'监测文件中的商品ID与当前商品不一致',
'currentItemId':item_id,
'fileItemIds':file_ids[:20],
'canForce':len(file_ids)==1
},409); return
rows=normalize_monitor_rows(parsed,item_id,force_bind=force_bind)
if not rows:
self.send_json({
'ok':False,
'code':'NO_VALID_ROWS',
'error':'没有识别到当前商品的有效“日期 + 销量”数据',
'fileItemIds':file_ids[:20]
},400); return
monitor_path(item_id).with_suffix('.json').write_text(json.dumps(rows,ensure_ascii=False,indent=2),'utf-8')
self.send_json({
'ok':True,'days':len(rows),
'firstSales':rows[0].get('salesDisplay'),
'lastSales':rows[-1].get('salesDisplay'),
'itemId':item_id
}); return
except Exception as e:
self.send_json({'ok':False,'code':'IMPORT_EXCEPTION','error':str(e)},400); return
if self.path=='/api/monitor-clear':
try:
n=int(self.headers.get('Content-Length','0')); data=json.loads(self.rfile.read(n).decode('utf-8') or '{}'); p=monitor_path(data.get('itemId')).with_suffix('.json')
if p.exists(): p.unlink()
self.send_json({'ok':True}); return
except Exception as e: self.send_json({'ok':False,'error':str(e)},400); return
if self.path=='/api/config':
try:
n=int(self.headers.get('Content-Length','0')); data=json.loads(self.rfile.read(n).decode('utf-8') or '{}')
save_config(data)
MODEL_PROBE=probe_model_api(timeout=18); MODEL_PROBE_AT=time.time()
if not MODEL_PROBE.get('ok'): self.send_json({'ok':False,'error':MODEL_PROBE.get('error') or 'API连接失败','probe':MODEL_PROBE},400); return
self.send_json({'ok':True,'model':load_config().get('model'),'probe':MODEL_PROBE}); return
except Exception as e: self.send_json({'ok':False,'error':str(e)},400); return
if self.path not in ('/api/analyze','/api/analyze-current'): self.send_error(404);return
try:
c=load_config(); model_ready=configured()
MODEL_PROBE=probe_model_api(timeout=15) if model_ready else {'ok':False,'stage':'config','error':'模型未配置,使用事实报告'}
MODEL_PROBE_AT=time.time()
n=int(self.headers.get('Content-Length','0'))
payload=json.loads(self.rfile.read(n).decode('utf-8') or '{}')
if self.path=='/api/analyze-current':
raw=payload.get('raw') if isinstance(payload,dict) else None
allow_partial=True
else:
raw=payload
allow_partial=False
if not isinstance(raw,dict):
self.send_json({'ok':False,'error':'分析数据格式错误:缺少 raw 商品数据'},400); return
raw['_model_available']=bool(MODEL_PROBE.get('ok'))
item_id=str(raw.get('product',{}).get('itemId') or '')
if not item_id:
self.send_json({'ok':False,'error':'采集数据缺少商品 itemId,请刷新商品页后重新采集'},400); return
has_facts=bool(raw.get('product',{}).get('title') or raw.get('product',{}).get('shop') or raw.get('sales',{}).get('currentPrice') or raw.get('sku') or raw.get('attributes') or raw.get('images',{}).get('main'))
if not has_facts:
self.send_json({'ok':False,'error':'采集数据中商品字段为空,请确认淘宝已登录且详情加载完成'},400); return
if not bool((raw.get('collection') or {}).get('reviewCollectionComplete')) and not allow_partial:
self.send_json({'ok':False,'error':'评论尚未确认完整。如需立即分析,请使用“基于当前数据开始分析”。'},409); return
mp=monitor_path(item_id).with_suffix('.json')
if mp.exists():
try: raw['monitoring']=json.loads(mp.read_text('utf-8'))
except Exception: raw['monitoring']=[]
tid='tmall_'+str(item_id or 'item')+'_'+time.strftime('%Y%m%d_%H%M%S')+'_'+uuid.uuid4().hex[:5]
summary=task_evidence_summary(raw);counts=summary['counts']
TASKS[tid]={'status':'queued','progress':20,'evidenceSummary':summary,'steps':{'collect':'done','evidence':'waiting','planner':'waiting','product':'waiting','fashion':'waiting','visual':'not_collected' if not counts['main'] else 'waiting','merchandising':'waiting','detail':'not_collected' if not counts['detail'] else 'waiting','review':'waiting','qa':'waiting','competition':'waiting','strategy':'waiting','plans':'waiting','editor':'waiting','report':'waiting'},'createdAt':time.time()}
threading.Thread(target=worker,args=(tid,raw),daemon=True).start()
self.send_json({'ok':True,'taskId':tid,'taskUrl':f'http://127.0.0.1:{PORT}/task/{tid}'})
except Exception as e: self.send_json({'ok':False,'error':str(e)},400)
def do_GET(self):
global MODEL_PROBE, MODEL_PROBE_AT
p=urlparse(self.path).path
if p=='/health':
c=load_config(); self.send_json({'ok':True,'serverVersion':SERVER_VERSION,'modelConfigured':configured(),'model':c.get('model') or None,'apiBase':c.get('api_base') or None,'configPath':c.get('_config_path'),'configError':c.get('_config_error') or None,'modelProbe':MODEL_PROBE,'port':PORT}); return
if p=='/api/prompts':
saved={}
if PROMPT_CONFIG.exists():
try: saved=json.loads(PROMPT_CONFIG.read_text('utf-8'))
except Exception: pass
self.send_json({'ok':True,'variables':PRODUCT_VARIABLES,'templates':{k:{'body':saved.get(k,{}).get('body',v.body),'variables':list(v.variables)} for k,v in TEMPLATES.items()}}); return
if p=='/prompts':
b=prompt_page().encode('utf-8'); self.send_response(200); self.send_header('Content-Type','text/html; charset=utf-8'); self.send_header('Content-Length',str(len(b))); self.end_headers(); self.wfile.write(b); return
if p=='/version':
self.send_json({'ok':True,'serverVersion':SERVER_VERSION,'port':PORT}); return
if p=='/api/model-test':
MODEL_PROBE=probe_model_api(timeout=18); MODEL_PROBE_AT=time.time(); self.send_json(MODEL_PROBE,200 if MODEL_PROBE.get('ok') else 502); return
if p.startswith('/api/monitor/'):
item_id=p.rsplit('/',1)[-1]; mp=monitor_path(item_id).with_suffix('.json')
if not mp.exists(): self.send_json({'ok':True,'days':0}); return
try:
rows=json.loads(mp.read_text('utf-8')); self.send_json({'ok':True,'days':len(rows),'firstSales':rows[0].get('salesDisplay') if rows else None,'lastSales':rows[-1].get('salesDisplay') if rows else None,'rows':rows}); return
except Exception as e: self.send_json({'ok':False,'error':str(e)},500); return
if p.startswith('/api/task/'):
tid=p.rsplit('/',1)[-1]; t=TASKS.get(tid); self.send_json(t if t else {'error':'task not found'},200 if t else 404); return
if p.startswith('/task/'):
tid=p.rsplit('/',1)[-1]; b=task_page(tid).encode();self.send_response(200);self.send_header('Content-Type','text/html; charset=utf-8');self.send_header('Content-Length',str(len(b)));self.end_headers();self.wfile.write(b);return
if p=='/task.js':
b=TASK_JS.encode();self.send_response(200);self.send_header('Content-Type','text/javascript; charset=utf-8');self.send_header('Content-Length',str(len(b)));self.end_headers();self.wfile.write(b);return
if p=='/report.css': self.send_file(ROOT/'report.css','text/css; charset=utf-8'); return
if p=='/report.js': self.send_file(ROOT/'report.js','text/javascript; charset=utf-8'); return
if p=='/report.html': self.send_file(ROOT/'report.html','text/html; charset=utf-8'); return
if p.startswith('/reports/'):
f=(REPORTS/p.split('/reports/',1)[1]).resolve()
if REPORTS.resolve() not in f.parents:self.send_error(403);return
ctype=mimetypes.guess_type(str(f))[0] or 'application/octet-stream'
if f.suffix=='.json':ctype='application/json; charset=utf-8'
elif f.suffix=='.html':ctype='text/html; charset=utf-8'
self.send_file(f,ctype);return
if p=='/':
b=control_page().encode('utf-8');self.send_response(200);self.send_header('Content-Type','text/html; charset=utf-8');self.send_header('Content-Length',str(len(b)));self.end_headers();self.wfile.write(b);return
self.send_error(404)
def main():
print(f'Sansong Ecommerce Product Loop V{SERVER_VERSION} running: http://127.0.0.1:{PORT}',flush=True)
print('Dedicated port 17962. Manual single-product collection; no recommendation scraping.',flush=True)
ThreadingHTTPServer(('127.0.0.1',PORT),H).serve_forever()
if __name__=='__main__': main()
DYNAMIC_PARAM_FAMILIES = {
'身份与合规':['品牌','产地','型号','执行标准','备案','许可证','等级'],
'规格与选择':['尺寸','尺码','规格','颜色','口味','容量','重量','净含量','版本','配置'],
'材质与成分':['材质','面料','成分','配料','配方','填充物'],
'功能与适用':['功能','功效','适用','肤质','人群','场景','兼容'],
'工艺与使用':['工艺','做工','版型','洗护','使用方法','保质期','储存','续航','性能'],
}
def evaluate_parameter_collection(attrs):
keys=list((attrs or {}).keys())
norm = {str(k).strip():str(v).strip() for k,v in (attrs or {}).items() if str(v).strip()}
found=[]
for canon, aliases in DYNAMIC_PARAM_FAMILIES.items():
hit=False
for k,v in norm.items():
if any(a in k for a in aliases) and v:
hit=True; break
if hit: found.append(canon)
status = 'collected' if norm else 'missing'
return {
'state':status,
'stateText': {'collected':'参数已采集并等待类目分析','missing':'未识别到有效参数'}.get(status,'参数已采集'),
'count': len(norm),
'keys': keys[:50],
'found': found,
'missing': []
}
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
cd "$(dirname "$0")/.."
exec ./start-tmall-ai.command
+5
View File
@@ -0,0 +1,5 @@
@echo off
cd /d %~dp0
if not exist config.json copy config.example.json config.json
python server.py
pause