677 lines
46 KiB
Python
677 lines
46 KiB
Python
import html
|
||
import json
|
||
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 {}; exp=a.get('experienceSolution') or {}; summary=exp.get('reportSummary') or {}
|
||
title=clean_display(summary.get('title') or product.get('title') or '商品分析报告',60)
|
||
payload=json.dumps(a,ensure_ascii=False,separators=(',',':')).replace('</','<\\/').replace('\u2028','\\u2028').replace('\u2029','\\u2029')
|
||
return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="generator" content="Sansong Product Intelligence V9.3.0"><title>'+H(title)+'</title><link rel="stylesheet" href="/report.css"></head><body><main id="app" class="report-app"></main><script>window.REPORT_DATA='+payload+';</script><script src="/report.js"></script></body></html>'
|