import json, re, math from openpyxl import Workbook from openpyxl.styles import Font, Alignment, PatternFill, Border, Side from openpyxl.utils import get_column_letter import os T = os.path.dirname(os.path.abspath(__file__)) + '/' L = json.load(open(T + 'bom_agg.json', encoding='utf-8')) TB = json.load(open(T + 'tb.json', encoding='utf-8')) LC = json.load(open(T + 'lcsc_cny.json', encoding='utf-8')) Q = {x['sid']: x for x in json.load(open(T + 'queries.json', encoding='utf-8'))} SETS = 1 # 1 底板 + 5 刀片 = 1 套 # 非阻容料:标题里必须出现的型号核心(去掉空格/横杠后大写匹配) CORE = { 'C382328': 'AO4407', 'C163651': 'SMBJ13A', 'C2887585': 'TL431', 'C49851': 'INA226', 'C5210749': 'LMR51430', 'C38117': 'SWPA4030S100', 'C76857': 'SPM6530T4R7', 'C2913202': 'ESP32S3WROOM1N16R8', 'C50951': '', 'C130026': 'TCA9548', 'C5215473': 'DAC63204', 'C478081': 'USB2517', 'C2762192': '24M', 'C165948': 'TYPEC31M12', 'C6186': 'AMS1117', 'C116575': 'T491A226', 'C19188869': 'PCIE', 'C20917': 'AO3400', 'C2891776': 'BZT52C8V2', 'C15127': 'AO3401', 'C8678': 'SS34', 'C2847671': '2510', 'C13564': 'NCP18XH103', 'C310990': 'F1206HC12', 'C151920': 'SMBJ22A', 'C42459160': 'CH224', 'C3188410': 'LM61495', 'C780205': 'SRP1265A4R7', 'C84248': 'TPS54622', 'C2653873': 'TPS25940', 'C8545': '2N7002', 'C310998': 'F1206SB2', 'C2688893': 'PCA9555', 'C225104': 'A1251', 'C353034': 'TS5A3157', 'C3828985': '2200', 'C5221288': 'YTC1P', 'C424561': 'BM2450DS', 'C2687116': 'USBLC62', 'C131941': 'TPS22918', 'C841779': '5MR', 'C22383480': '470UF', } CORE['C5210749'] = 'LMR51430YDDC' # 封装必须对(标题里常把同型号不同封装混卖) PKG = {'C2887585': 'SOT23', 'C20917': 'SOT23', 'C15127': 'SOT23', 'C8545': 'SOT23', 'C382328': 'SOP8|SOIC8', 'C6186': 'SOT223', 'C8678': 'SMA|DO214AC', 'C2891776': 'SOD123'} import re as _re SPECIAL = { 'C19188869': lambda U, t: '64P' in U and _re.search(r'X4|4X', U) and _re.search(r'贴片|SMT', U) and '164' not in U, 'C5221288': lambda U, t: _re.search(r'贴片|SMT', U) and _re.search(r'POGO|顶针|弹簧针', U) and not _re.search(r'夹|间距|烧录', U), 'C116575': lambda U, t: _re.search(r'22UF|226', U) and '10V' in U and _re.search(r'A型|3216', U), 'C310998': lambda U, t: _re.search(r'(?= 0 else None if code: toks.add(code) if pf >= 1e3: toks.add(f"{pf/1e3:g}NF") if pf >= 1e4: toks.add(f"{pf/1e6:g}UF") return toks def relevant(sid, r, it): t = norm(it['title'] + ' ' + it['tags']) q = Q[sid]['q'] U = (it['title'] + ' ' + it['tags']).upper() if sid in SPECIAL: return SPECIAL[sid](U, t) if sid in CORE: c = CORE[sid] if sid == 'C50951': return '2.54' in it['title'] and '排针' in it['title'] if sid == 'C841779': return bool(re.search(r'(?= 0: forms.add(f"{int(round(ohm / 10**e)):03d}{e}") if u == 'R': forms |= {num + 'R0', num + 'R00'} if any(re.search(r'(? 1 else None def lcsc_price(sid, qty): v = LC.get(sid) or {} ps = v.get('prices') or [] best = None for p in ps: if qty >= p['ladder'] or best is None: best = p return (best['currencyPrice'], best['ladder']) if best else (None, None) rows = [] for r in L: sid = r['sid'] or '' if not sid.startswith('C'): continue items = [i for i in TB.get(sid, {}).get('items', []) if i['price']] rel = [i for i in items if relevant(sid, r, i)] for i in rel: i['p'] = float(i['price']); i['s'] = sales_n(i['sales']); i['pack'] = pack_of(i['title']) # 选店:先按销量挑,同店只取一条,再补最低价 picks, shops = [], set() for i in sorted(rel, key=lambda i: (i.get('series', False), -i['s'])): if i['shop'] in shops: continue picks.append(i); shops.add(i['shop']) if len(picks) == 3: break cheap = [i for i in sorted(rel, key=lambda i: i['p']) if i['shop'] not in shops and i['s'] > 0] if cheap: picks.append(cheap[0]) picks.sort(key=lambda i: i['p']) lp, ll = lcsc_price(sid, r['total']) rows.append(dict(r=r, picks=picks, nrel=len(rel), nall=len(items), lp=lp, ll=ll)) # ---------- Excel ---------- wb = Workbook() ws = wb.active; ws.title = 'BOM+淘宝比价' hdr = ['#', '分类', '型号', '厂商', '立创料号', '规格', '封装', '底板位号', '刀片位号(每片)', '底板数', '刀片每片数', '总数(1底板+5刀片)', '立创单价¥', '立创价阶梯', '立创小计¥', '立创链接', '淘宝搜索词', '淘宝搜索链接', '备注'] for k in range(1, 5): hdr += [f'店{k} 店铺', f'店{k} 列表价¥', f'店{k} 付款人数', f'店{k} 标题', f'店{k} 链接'] ws.append(hdr) def cat(r): m, fp = r['mid'] or '', r['fp'] or '' if re.match(r'0402WGF|RHC|FRC|HoGT|NCP18', m): return '电阻' if re.match(r'CL\d|GRM|0402CG|T491|470uF|6.3TPV', m): return '电容' if re.match(r'SPM|SWPA|SRP', m): return '电感' if re.match(r'AO|2N7|SS34|SMBJ|BZT|USBLC', m): return '分立/保护' if re.match(r'F1206', m): return '保险丝' if re.search(r'TYPE-C|PCIE|A1251|2510|BM24|YTC|排针', m): return '连接器' if 'MHZ' in m: return '晶振' return 'IC/模组' ORDER = ['IC/模组', '电感', '分立/保护', '保险丝', '晶振', '连接器', '电容', '电阻'] rows.sort(key=lambda x: (ORDER.index(cat(x['r'])), x['r']['mid'])) total_lcsc = 0 def note(x): r = x['r']; sid = r['sid']; ns = [] if sid in GENERIC: ns.append(GENERIC[sid]) if not x['picks']: ns.append('淘宝没搜到可对应的链接') c = cat(r) if x['lp'] and x['picks'] and c in ('IC/模组',) and min(i['p'] for i in x['picks']) < 0.3 * x['lp']: ns.append('淘宝最低价不到立创的 30%,警惕散新/翻新/引流价') if not x['lp']: ns.append('立创接口无报价(料号下架或非立创自营)') return ';'.join(ns) for n, x in enumerate(rows, 1): r = x['r']; sid = r['sid']; pid = (LC.get(sid) or {}).get('pid') sub = round(x['lp'] * r['total'], 2) if x['lp'] else None total_lcsc += sub or 0 q = Q[sid]['q'] row = [n, cat(r), r['mid'], r['m'], sid, r['desc'], r['fp'], ','.join(r['base']), ','.join(r['blade']), len(r['base']), len(r['blade']), r['total'], x['lp'], f"≥{x['ll']}" if x['ll'] else '', sub, f"https://item.szlcsc.com/{pid}.html" if pid else '', q, 'https://s.taobao.com/search?q=' + __import__('urllib.parse').parse.quote(q), note(x)] for i in x['picks']: url = ('https://detail.tmall.com/item.htm?id=' if i['tmall'] else 'https://item.taobao.com/item.htm?id=') + i['id'] row += [i['shop'], i['p'], i['sales'], ('【系列链接,需在规格里选阻值】' if i.get('series') else '') + i['title'], url] ws.append(row) thin = Side(style='thin', color='CCCCCC') for c in ws[1]: c.font = Font(bold=True, color='FFFFFF'); c.fill = PatternFill('solid', fgColor='305496') c.alignment = Alignment(wrap_text=True, vertical='center') widths = {19: 30, 1: 4, 2: 9, 3: 22, 4: 14, 5: 11, 6: 26, 7: 18, 8: 18, 9: 18, 10: 6, 11: 6, 12: 8, 13: 9, 14: 8, 15: 9, 16: 12, 17: 22, 18: 12} for col in range(1, ws.max_column + 1): w = widths.get(col) if w is None: w = {1: 14, 2: 9, 3: 9, 4: 36, 5: 10}[(col - 20) % 5 + 1] ws.column_dimensions[get_column_letter(col)].width = w for row in ws.iter_rows(min_row=2): for c in row: c.alignment = Alignment(vertical='top', wrap_text=False) if isinstance(c.value, str) and c.value.startswith('https://'): c.hyperlink = c.value; c.value = '打开'; c.font = Font(color='0563C1', underline='single') ws.freeze_panes = 'D2' ws.auto_filter.ref = ws.dimensions ws.append([]) ws.append(['', '', '立创参考总价(按总数取对应阶梯价)', '', '', '', '', '', '', '', '', '', '', '', round(total_lcsc, 2)]) # 说明页 ns = wb.create_sheet('说明') notes = [ '数据来源', f'· 器件清单:从 EasyEDA 工程 PhoneRail-R2 的 13 页原理图逐页读出(底板 8 页 + 刀片 5 页),按立创料号合并。', '· 数量按「1 块底板 + 5 片刀片」一套计算;不含 J1 金手指(板边铜箔,不采购)和 J5 电池排线焊盘(非器件)。', '· 立创价:LCSC 官方接口的人民币报价,按本套总数取阶梯价;立创商城国内站与此可能略有出入。', '· 淘宝:登录后按搜索词逐个搜索(每次间隔 30 秒),取前 24 条结果;按标题匹配型号/阻值容值/封装过滤掉不相关的,', ' 再挑销量最高的 3 家不同店铺 + 1 家最低价店铺,按价格排序。', '', '⚠ 看价格前必读', '· 淘宝「列表价」是该链接所有 SKU 里最低的那一个,不一定是你要的型号/阻值/数量档;多规格链接(如阻值全系列)尤其如此。', '· 阻容类淘宝通常按包(50/100 只)卖,列表价是一包的价格,不是单颗价;立创价是单颗价。两者不能直接相除比较。', '· IC 类如果淘宝价明显低于立创价(比如低到 1/3 以下),多为拆机、翻新、散新或引流价,关键器件(TPS54622、TPS25940、', ' INA226、USB2517、DAC63204、ESP32 模组)建议走立创或品牌/授权店。', '· 淘宝结果随时间、账号和地区变化;链接在抓取时有效。', ] for s in notes: ns.append([s]) ns.column_dimensions['A'].width = 120 ns['A1'].font = Font(bold=True); ns['A8'].font = Font(bold=True, color='C00000') wb.move_sheet('说明', offset=-1) out = 'D:/DCDC/BOM/PhoneRail-R2_BOM_淘宝比价.xlsx' import os; os.makedirs('D:/DCDC/BOM', exist_ok=True) wb.save(out) json.dump([{'mid': x['r']['mid'], 'sid': x['r']['sid'], 'cat': cat(x['r']), 'total': x['r']['total'], 'lp': x['lp'], 'nrel': x['nrel'], 'nall': x['nall'], 'picks': [{k: i[k] for k in ('shop', 'p', 'sales', 'title', 'id', 'tmall')} for i in x['picks']]} for x in rows], open(T + 'rows.json', 'w', encoding='utf-8'), ensure_ascii=False, indent=1) print(out, len(rows), 'lcsc total', round(total_lcsc, 2)) for x in rows: if len(x['picks']) < 2: print('FEW', x['r']['sid'], x['r']['mid'], x['nrel'], '/', x['nall'])