文件
phonerail/BOM/_src/build.py

252 行
14 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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'(?<![\d.])2A', U) and '慢' in U and '1206' in U,
'C2762192': lambda U, t: '24M' in U and '3225' in U and _re.search(r'(?<!\d)9PF|负载9/', U) and '有源' not in U,
'C3828985': lambda U, t: '2200UF' in t and '63V' in t and _re.search(r'10X105|6\.3TPV', t + U),
'C225104': lambda U, t: 'A1251WV' in t,
}
E24 = [1.0, 1.1, 1.2, 1.3, 1.5, 1.6, 1.8, 2.0, 2.2, 2.4, 2.7, 3.0, 3.3, 3.6, 3.9, 4.3, 4.7, 5.1, 5.6, 6.2, 6.8, 7.5, 8.2, 9.1]
GENERIC = {'C5221288': '通用顶针,须按 Ø2mm / 高 3.5mm / 贴片 在规格里核对',
'C19188869': '非原厂同款(原设计 SOFNG PCIE-64P11L,板上高 11.1mm 带定位柱),下单前核对尺寸/定位柱',
'C2913202': '2026-09-13 定为 N16R8;多规格链接要选 N16R8 这一项',
'C3828985': '原设计为 Rubycon 6.3TPV 固态/高分子,替代品需核对 ESR≤60mΩ、纹波电流',
'C2762192': '原设计 NDK ±10ppm 9pF;替代品须 9pF 负载电容'}
def norm(s):
return re.sub(r'[\s\-_/·.,,()()]', '', s.upper())
def cap_tokens(desc):
v = desc.split()[0] # e.g. 100nF
m = re.match(r'([\d.]+)(pF|nF|uF)', v)
num, u = float(m.group(1)), m.group(2)
pf = num * {'pF': 1, 'nF': 1e3, 'uF': 1e6}[u]
toks = {v.upper()}
# 三位码
e = int(math.floor(math.log10(pf))) - 1
code = f"{int(round(pf / 10**e)):02d}{e}" if e >= 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'(?<![\d.])(5MR|R005|0\.005R|5毫欧)', it['title'].upper()))
if sid in PKG and not re.search(PKG[sid], t):
return False
return c in t
fp = r['fp'] or ''
pk = re.search(r'(0402|0603|0805|1206|2512)', fp).group(1)
if pk not in t:
return False
if '贴片电阻' in q:
raw = (it['title'] + ' ' + it['tags']).upper().replace('Ω', '').replace('欧', '')
if '热敏' in raw or '排阻' in raw or '采样' in raw and pk != '2512':
return False
v = q.split()[1].upper() # 100K / 43.2K / 2R
m = re.match(r'([\d.]+)([KMR])', v); num, u = m.group(1), m.group(2)
forms = {v, num + u}
if '.' in num:
a, b = num.split('.'); forms.add(f'{a}{u}{b}')
mult = {'R': 1, 'K': 1e3, 'M': 1e6}[u]
ohm = float(num) * mult
e = int(math.floor(math.log10(ohm))) - 2
if e >= 0: forms.add(f"{int(round(ohm / 10**e)):03d}{e}")
if u == 'R': forms |= {num + 'R0', num + 'R00'}
if any(re.search(r'(?<![\d.])' + re.escape(f) + r'(?![\d])', raw) for f in forms):
return True
mant = ohm / 10 ** math.floor(math.log10(ohm))
if any(abs(mant - e) < 1e-6 for e in E24) and re.search(r'全系列|系列|全阻值|多阻值|阻值齐全', it['title']):
it['series'] = True
return True
return False
if '贴片电容' in q:
raw = (it['title'] + ' ' + it['tags']).upper().replace(' ', '')
volt = re.search(r'(\d+(\.\d)?V)', r['desc']).group(1).upper()
if not re.search(r'(?<![\d.])' + re.escape(volt), raw):
return False
return any(re.search(r'(?<![\d.])' + re.escape(k) + r'(?![\d])', raw) for k in cap_tokens(r['desc']))
return True
def sales_n(s):
m = re.search(r'([\d.]+)(万)?\+?人', s)
if not m: return 0
return float(m.group(1)) * (10000 if m.group(2) else 1)
def pack_of(title):
m = re.search(r'[((]?(\d+)\s*(只|个|颗|片|pcs|PCS)[))]?', title)
return int(m.group(1)) if m and int(m.group(1)) > 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'])