import json, os, threading, time, uuid, traceback, html, base64, csv, io, zipfile, re, mimetypes, hashlib, urllib.request, urllib.error from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout 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.3.0' 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=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 # 分析设置硬超时,避免模型/API异常导致任务永久停留在“处理中”。 timeout_seconds=max(60,int(os.getenv('TMALL_ANALYSIS_TIMEOUT','900'))) pool=ThreadPoolExecutor(max_workers=1) future=pool.submit(analyze,raw,progress) try: result=future.result(timeout=timeout_seconds) except FutureTimeout: future.cancel() # 用无模型本地路径生成可交付报告,而不是让任务卡死。 result=analyze({**raw,'_model_available':False},progress) except Exception as analysis_error: # 模型返回结构异常或单个分析模块崩溃时,退回证据驱动本地报告。 result=analyze({**raw,'_model_available':False},progress) result.setdefault('meta',{})['reportNotice']=f'部分分析模块异常,已输出证据报告:{analysis_error}' finally: pool.shutdown(wait=False, cancel_futures=True) 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'''行业平台报告生成中
SANXIAN · PRODUCT INTELLIGENCE

正在生成商品产品分析报告

按真实采集证据完成核验、评论聚合、主图分析、详情参数解读、活动分析、产品定义、推荐方案与落地方式。

8%
本次证据
生成进度缺失资料会明确标记,不会补写

''' 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): if not isinstance(data, dict): raise ValueError('配置请求必须是 JSON 对象') p=ROOT/'config.json' # 完整包首次解压或被移动后,确保配置目录存在再进行原子写入。 p.parent.mkdir(parents=True, exist_ok=True) 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'''三笙 · 电商商品分析闭环 V{SERVER_VERSION}
ONE-CLICK LOCAL SERVICE

三笙 · 电商商品分析引擎 V{SERVER_VERSION}

单商品手动深采;类目不限,参数与SKU动态识别,评论可持续采集,也可随时基于当前数据开始分析。
本地服务
运行正常 · V{SERVER_VERSION}
模型配置
{'已配置 '+model if is_cfg else '待配置'}
API连接
{'连通 ✓' if api_ok else '待检测/失败'}

首次只需要填一次 API Key

默认已为 BananaRouter + gpt-5.5 配好。API Key 只保存在本机 server/config.json,不会写入 Chrome 扩展。

{'配置错误:'+cfg_err if cfg_err else ('API错误:'+probe if (is_cfg and not api_ok and probe) else '')}

下一步

Chrome 只需加载一次 extension 文件夹。之后每次使用:双击启动 → 打开已登录的天猫商品页 → 可选导入监测数据 → 点击扩展“开始深度采集”。

''' def prompt_page(): saved={} if PROMPT_CONFIG.exists(): try: saved=json.loads(PROMPT_CONFIG.read_text('utf-8')) if not isinstance(saved,dict): saved={} 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()} vars_html=''.join(f'
{html.escape(k)}
{html.escape(v)}
' for k,v in PRODUCT_VARIABLES.items()) template_html=''.join(f'

{html.escape(k)}

变量:{html.escape("、".join(v["variables"]) or "自定义")}

' for k,v in items.items()) return ('' '提示词配置' '

提示词可视化配置

编辑后保存,下一次分析任务生效。

商品变量说明

'+vars_html+'
'+template_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')) if not isinstance(saved,dict): saved={} 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(f'Dedicated port {PORT}. 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': [] }