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,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
|
||||
Reference in New Issue
Block a user