Initial commit of Tmall new-product analysis loop v9.2.5.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
+3892
File diff suppressed because it is too large
Load Diff
@@ -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": {}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,26 @@
|
||||
/* 独立报告视图:页面只负责渲染 JSON,不在 Python 中拼接 HTML。 */
|
||||
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>`; });
|
||||
@@ -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('&','&')
|
||||
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': []
|
||||
}
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")/.."
|
||||
exec ./start-tmall-ai.command
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
cd /d %~dp0
|
||||
if not exist config.json copy config.example.json config.json
|
||||
python server.py
|
||||
pause
|
||||
Reference in New Issue
Block a user