Compare commits
25 Commits
f35dcc4a18
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8754669d40 | |||
| b1f0b4651a | |||
| 3fe6876ef0 | |||
| 89b89d4f19 | |||
| 637764f622 | |||
| d36669103e | |||
| c9a2d13945 | |||
| f6541d291e | |||
| d29cbed1fa | |||
| 89bdf2ff61 | |||
| 0c01b91fd7 | |||
| 0ba4fef55e | |||
| 305747d4ba | |||
| ce80e50aec | |||
| b76f51a1aa | |||
| 0cb08642aa | |||
| d3d1a8650e | |||
| dfddbb5ea0 | |||
| ac9720e7de | |||
| a64725d60c | |||
| 1b420cd492 | |||
| 74e50a2b30 | |||
| f12b3d5ecd | |||
| 3c0e5ed49c | |||
| f6ba48a0d0 |
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.swp
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Compose 会按以下优先级取值:
|
||||||
|
# 1. 当前 shell 的环境变量
|
||||||
|
# 2. 项目根目录下的 .env
|
||||||
|
# 3. docker-compose.yml 里的默认值
|
||||||
|
|
||||||
|
# 远程 MySQL 地址
|
||||||
|
MYSQL_HOST=your.mysql.host
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
|
||||||
|
# 容器时区
|
||||||
|
TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
# 管理账号:用于 schema / seed 装载
|
||||||
|
MYSQL_ROOT_USER=root
|
||||||
|
MYSQL_ROOT_PASSWORD=mobilemodels_root_change_me
|
||||||
|
|
||||||
|
# 业务数据库名
|
||||||
|
MYSQL_DATABASE=mobilemodels
|
||||||
|
|
||||||
|
# 只读账号:用于页面 SQL 查询和第三方联调
|
||||||
|
MYSQL_READER_USER=mobilemodels_reader
|
||||||
|
MYSQL_READER_PASSWORD=mobilemodels_reader_change_me
|
||||||
|
|
||||||
|
# 是否在容器启动或原始数据同步后自动装载 MySQL
|
||||||
|
# 远程 MySQL 场景建议保持 0
|
||||||
|
# 本地测试 MySQL 场景可设置为 1
|
||||||
|
MYSQL_AUTO_LOAD=0
|
||||||
|
|
||||||
|
# 是否启用项目内的每日自动同步
|
||||||
|
SYNC_SCHEDULE_ENABLED=0
|
||||||
|
|
||||||
|
# 每日自动同步时间,格式 HH:MM
|
||||||
|
SYNC_SCHEDULE_TIME=03:00
|
||||||
|
|
||||||
|
# GitHub 加速前缀,留空表示直连
|
||||||
|
# 例如 https://ghfast.top/
|
||||||
|
GITHUB_PROXY_PREFIX=
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends git ca-certificates default-mysql-client tzdata \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
EXPOSE 8123
|
||||||
|
|
||||||
|
CMD ["sh", "tools/container_start.sh"]
|
||||||
@@ -1,132 +1,535 @@
|
|||||||
# 手机品牌型号汇总
|
# 项目使用文档
|
||||||
|
|
||||||
[](https://github.com/KHwang9883/MobileModels/issues)
|
## 1. 文档概览
|
||||||
[](https://github.com/KHwang9883/MobileModels/pulls)
|
|
||||||
[](https://github.com/KHwang9883/MobileModels)
|
|
||||||
[](https://github.com/KHwang9883/MobileModels)
|
|
||||||
[](https://creativecommons.org/licenses/by-nc-sa/4.0/)
|
|
||||||
|
|
||||||
汇总各厂商上市的手机型号与对应的传播名。
|
### 1.1 文档目标
|
||||||
|
|
||||||
[English](README_en.md)
|
这份文档用于说明 MobileModels 的使用方式、数据接入方式、维护方式和排查方式。文档不再按“项目说明 / 使用说明 / 数据库说明”拆开,而是整理成一份统一文档,方便不同角色在同一份上下文里完成阅读。
|
||||||
|
|
||||||
- ✅ 包含
|
### 1.2 适用角色
|
||||||
- ⏹ 仅部分包含
|
|
||||||
- ❌ 不包含
|
|
||||||
|
|
||||||
| 名称 | 品牌 | 汇总范围 | codename | 海外机型 | 备注 |
|
- 产品或业务使用者:需要查设备、看字段、理解页面入口
|
||||||
| :-: | :-: | :-: | :-: | :-: | :-: |
|
- 数据维护者:需要维护品牌、来源排序、同步数据、刷新索引
|
||||||
| [360shouji](brands/360shouji.md) | 360 手机 | 全部 360/奇酷手机 | ❌ | ❌ | -- |
|
- 服务端接入方:需要接 MySQL、按统一规则查询设备数据
|
||||||
| [apple_all](brands/apple_all.md) | Apple | 全部 iPhone/iPad/iPod touch/Apple Watch/Apple TV/Apple Vision | ✅ | ✅ | -- |
|
- 运维维护者:需要启动服务、检查配置、处理运行问题
|
||||||
| [apple_all_en](brands/apple_all_en.md) | Apple | 全部 iPhone/iPad/iPod touch/Apple Watch/Apple TV/Apple Vision | ✅ | ✅ | 英文版 |
|
|
||||||
| [apple_cn](brands/apple_cn.md) | Apple | 全部国行 iPhone/iPad/iPod touch/Apple Watch/Apple Vision | ✅ | ❌ | -- |
|
|
||||||
| [asus_cn](brands/asus_cn.md) | 华硕 (ASUS) | ROG Phone 等 | ✅ | ❌ | -- |
|
|
||||||
| [asus_en](brands/asus_en.md) | 华硕 (ASUS) | ROG Phone/Zenfone | ✅ | ✅ | 英文版 |
|
|
||||||
| [blackshark](brands/blackshark.md) | 黑鲨 (Black Shark) | 全部机型 | ✅ | ✅ | -- |
|
|
||||||
| [blackshark_en](brands/blackshark_en.md) | 黑鲨 (Black Shark) | 全部机型 | ✅ | ✅ | 英文版 |
|
|
||||||
| [coolpad](brands/coolpad.md) | 酷派 (Coolpad) | 酷派近年智能手机机型 | ❌ | ❌ | -- |
|
|
||||||
| [google](brands/google.md) | Google | Google Pixel 手机/平板/手表 | ✅ | ✅ | 英文版 |
|
|
||||||
| [honor_cn](brands/honor_cn.md) | 荣耀 (HONOR) | 荣耀手机/平板/笔记本电脑/智慧屏/穿戴设备,仅包含国行型号 | ⏹ | ❌ | -- |
|
|
||||||
| [honor_global_en](brands/honor_global_en.md)| 荣耀 (HONOR) | 荣耀手机/平板,仅包含海外型号 | ⏹ | ✅ | 英文版 |
|
|
||||||
| [huawei_cn](brands/huawei_cn.md) | 华为 (HUAWEI) | 华为 Mate/Pura/nova/G/麦芒/畅享系列、平板电脑、MateBook、智慧屏及穿戴设备,仅包含国行型号 | ⏹ | ❌ | [其他早期型号参阅此处](misc/early-huawei-models.md) |
|
|
||||||
| [huawei_global_en](brands/huawei_global_en.md)| 华为 (HUAWEI) | 华为 Mate/Pura/nova/Y 系列及平板电脑,仅包含海外型号 | ⏹ | ⏹ | 英文版 |
|
|
||||||
| [lenovo_cn](brands/lenovo_cn.md) | 联想 (Lenovo) | 联想品牌 2017 年起上市的机型、ZUK 全部机型 | ✅ | ❌ | -- |
|
|
||||||
| [letv](brands/letv.md) | 乐视 (Letv) | 全部手机机型 | ❌ | ❌ | 不包含电视产品 |
|
|
||||||
| [meizu](brands/meizu.md) | 魅族 (MEIZU) | 全部机型 | ✅ | ✅ | -- |
|
|
||||||
| [meizu_en](brands/meizu_en.md) | 魅族 (MEIZU) | 全部机型 | ✅ | ✅ | 英文版 |
|
|
||||||
| [mitv_cn](brands/mitv_cn.md) | 小米 (Xiaomi) | 全部国行小米/Redmi 电视、机顶盒 | ❌ | ❌ | -- |
|
|
||||||
| [mitv_global_en](brands/mitv_global_en.md) | 小米 (Xiaomi) | 全部小米/Redmi 电视、机顶盒、智能电视棒,仅包含海外型号 | ❌ | ✅ | 英文版 |
|
|
||||||
| [motorola_cn](brands/motorola_cn.md) | 摩托罗拉 (Motorola) | 2015 年起上市的机型 | ✅ | ❌ | -- |
|
|
||||||
| [nokia_cn](brands/nokia_cn.md) | 诺基亚 (Nokia) | 2017 年起由 HMD Global 制造的智能手机机型 | ✅ | ❌ | -- |
|
|
||||||
| [nothing](brands/nothing.md) | Nothing | 全部机型 | ✅ | ✅ | 英文版 |
|
|
||||||
| [nubia](brands/nubia.md) | 努比亚 (nubia) | 全部机型 | ❌ | ⏹ | -- |
|
|
||||||
| [oneplus](brands/oneplus.md) | 一加 (OnePlus) | 全部机型 | ✅ | ✅ | -- |
|
|
||||||
| [oneplus_en](brands/oneplus_en.md) | 一加 (OnePlus) | 全部机型 | ✅ | ✅ | 英文版 |
|
|
||||||
| [oppo_cn](brands/oppo_cn.md) | OPPO | 2018 年起新型号命名方式的国行机型 | ⏹ | ❌ | -- |
|
|
||||||
| [oppo_global_en](brands/oppo_global_en.md) | OPPO | 2018 年起上市的海外机型 | ⏹ | ⏹ | 英文版 |
|
|
||||||
| [realme_cn](brands/realme_cn.md) | 真我 (realme) | 全部国行机型 | ⏹ | ❌ | -- |
|
|
||||||
| [realme_global_en](brands/realme_global_en.md) | 真我 (realme) | 全部海外机型 | ⏹ | ✅ | 英文版 |
|
|
||||||
| [samsung_cn](brands/samsung_cn.md) | 三星 (Samsung) | Galaxy S/Note/A/Z/M/C/J/On/Tab/心系天下系列及个别其他机型,仅包含国行型号 | ✅ | ❌ | [其他早期型号参阅此处](misc/early-samsung-models.md) |
|
|
||||||
| [samsung_global_en](brands/samsung_global_en.md) | 三星 (Samsung) | Galaxy S/Note/A/Z/M/F 系列,2019 年起上市的机型 | ✅ | ⏹ | 英文版 |
|
|
||||||
| [smartisan](brands/smartisan.md) | 坚果 (Smartisan) | 全部机型 | ✅ | ❌ | -- |
|
|
||||||
| [sony](brands/sony.md) | 索尼 (SONY) | 2015 年起上市的机型 | ✅ | ✅ | 英文版 |
|
|
||||||
| [sony_cn](brands/sony_cn.md) | 索尼 (SONY) | 2015 年起上市的国行机型 | ✅ | ❌ | -- |
|
|
||||||
| [vivo_cn](brands/vivo_cn.md) | vivo | 2018 年起新型号命名方式的国行机型 | ✅ | ❌ | -- |
|
|
||||||
| [vivo_global_en](brands/vivo_global_en.md) | vivo | 2019 年起上市的海外机型 | ⏹ | ⏹ | 英文版 |
|
|
||||||
| [xiaomi](brands/xiaomi.md) | 小米 (Xiaomi) | 小米/REDMI/POCO 手机 & 平板等 | ✅ | ✅ | -- |
|
|
||||||
| [xiaomi_cn](brands/xiaomi_cn.md) | 小米 (Xiaomi) | 小米/REDMI 手机 & 平板等 | ✅ | ✅ | 英文版 |
|
|
||||||
| [xiaomi_en](brands/xiaomi_en.md) | 小米 (Xiaomi) | 小米/REDMI/POCO 手机 & 平板等 | ✅ | ✅ | 英文版 |
|
|
||||||
| [xiaomi-wear](brands/xiaomi-wear.md) | 小米 (Xiaomi) | 小米/Redmi 手表、手环、TWS 等穿戴设备 | ⏹ | ✅ | TWS 不包含外包型号;暂不含儿童手表型号 |
|
|
||||||
| [zhixuan](brands/zhixuan.md) | 华为智选 | U-MAGIC 优畅享/电信麦芒/NZONE/Hi nova/雷鸟 FFALCON/TD Tech/WIKO | ⏹ | ❌ | -- |
|
|
||||||
| [zte_cn](brands/zte_cn.md) | 中兴 (ZTE) | 2017 年起上市的机型 | ❌ | ❌ | -- |
|
|
||||||
|
|
||||||
## 更新日志
|
### 1.3 建议阅读路径
|
||||||
|
|
||||||
参见 [CHANGELOG.md](CHANGELOG.md)
|
- 只想先跑起来:先看 `2. 快速开始`
|
||||||
|
- 只想用页面:先看 `3. 页面使用`
|
||||||
|
- 只想接数据库:先看 `4. 数据接入`
|
||||||
|
- 负责维护和发布:重点看 `5. 数据维护`、`6. 配置与运行`、`7. 排查指南`
|
||||||
|
|
||||||
## 许可
|
## 2. 快速开始
|
||||||
|
|
||||||
<a rel="license" href="https://creativecommons.org/licenses/by-nc-sa/4.0/"><img alt="知识共享许可协议" style="border-width:0" src="https://i.creativecommons.org/l/by-nc-sa/4.0/88x31.png" /></a><br />本作品采用 <a rel="license" href="https://creativecommons.org/licenses/by-nc-sa/4.0/">知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议</a> 进行许可。
|
### 2.1 项目提供什么
|
||||||
|
|
||||||
## 项目历史
|
MobileModels 将下面几类能力放在一个项目里统一交付:
|
||||||
|
|
||||||
### 2024 年 3 月
|
- 设备查询页面
|
||||||
- 将本项目 csv 及脚本迁移至 [此 repo](https://github.com/KHwang9883/MobileModels-csv),使用 GitHub Actions 自动更新。
|
- 数据管理页面
|
||||||
|
- 文档查看页面
|
||||||
|
- 设备索引构建
|
||||||
|
- MySQL schema 与 seed 生成
|
||||||
|
- 原始数据同步与自动调度
|
||||||
|
|
||||||
### 2022 年 4 月
|
使用时不需要分别启动多个服务,默认通过 Docker Compose 统一运行。
|
||||||
- 新增 [各大 Android 厂商 BL 解锁/内核开源情况](https://github.com/KHwang9883/bootloader-kernel-source) 汇总(已停更)。
|
|
||||||
|
|
||||||
### 2021 年 12 月
|
### 2.2 启动服务
|
||||||
- 新增 [各品牌型号命名规则](misc/naming-rules.md) 汇总。
|
|
||||||
|
|
||||||
### 2019 年 7 月
|
在项目根目录执行:
|
||||||
- 文档版停止维护。
|
|
||||||
|
|
||||||
### 2019 年 4 月
|
```bash
|
||||||
- 文档版迁移至 GitHub 直链下载。
|
docker compose up --build -d
|
||||||
- 新增英文版。
|
```
|
||||||
|
|
||||||
### 2019 年 3 月
|
如果需要同时启动本地测试 MySQL:
|
||||||
- 文档版迁移至微云。
|
|
||||||
|
|
||||||
### 2018 年 11 月
|
```bash
|
||||||
- 项目同步至 GitHub。
|
docker compose -f docker-compose.yml -f docker-compose.test.yml up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
### 2018 年 7 月
|
如果需要覆盖默认环境变量:
|
||||||
- 由于小米社区帖子失效,「手机品牌型号汇总」项目公开发布,提供文档版百度网盘下载。
|
|
||||||
|
|
||||||
### 2016 年 3 月
|
```bash
|
||||||
- 小米手机型号汇总发布至 [小米社区](http://bbs.xiaomi.cn/t-12641411)(帖子已失效)。
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
### 2016 年 2 月
|
### 2.3 页面入口
|
||||||
- 我开始汇总一些国内手机品牌的型号,「手机品牌型号汇总」的雏形诞生。
|
|
||||||
|
|
||||||
[](https://starchart.cc/KHwang9883/MobileModels)
|
- `设备查询`:`http://127.0.0.1:8123/web/device_query.html`
|
||||||
|
- `数据管理`:`http://127.0.0.1:8123/web/brand_management.html`
|
||||||
|
- `使用文档`:`http://127.0.0.1:8123/web/device_query.html?view=docs`
|
||||||
|
|
||||||
## 参考资料
|
### 2.4 停止与重置
|
||||||
|
|
||||||
- [电信设备终端网](http://zd.taf.org.cn)
|
停止服务:
|
||||||
- [产品认证证书查询](http://webdata.cqccms.com.cn/webdata/query/CCCCerti.do)
|
|
||||||
- [工业和信息化部政务服务平台](https://ythzxfw.miit.gov.cn/resultQuery)
|
|
||||||
- [产品库-中国电信天翼终端信息平台](http://surfing.tydevice.com/)
|
|
||||||
- [Google Play 支持的设备](http://storage.googleapis.com/play_public/supported_devices.html)
|
|
||||||
- [Wi-Fi Alliance](https://www.wi-fi.org)
|
|
||||||
- [Bluetooth Launch Studio](https://launchstudio.bluetooth.com/Listings/Search)
|
|
||||||
- [Xiaomi Firmware Updater](https://xiaomifirmwareupdater.com/)
|
|
||||||
- [Huawei Open Source Release Center](https://consumer.huawei.com/en/opensource/)
|
|
||||||
- [ReaMEIZU](https://reameizu.com/)
|
|
||||||
- [The Apple Wiki](https://theapplewiki.com/)
|
|
||||||
- [ipsw.me](https://ipsw.me)
|
|
||||||
- [XDA Developers](https://www.xda-developers.com)
|
|
||||||
- [Huawei Firmware Database](https://pro-teammt.ru/en/online-firmware-database-ru/)
|
|
||||||
- [XSMS IMEI 数据库](http://xsms.com.ua/phone/imei/all/1)
|
|
||||||
- [Android Dumps](https://dumps.tadiphone.dev/dumps)
|
|
||||||
- [Lenovo Android タブレット一覧](https://idomizu.dev/archives/20150)
|
|
||||||
|
|
||||||
以及各品牌官网、论坛、微博等,恕不一一列出
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
## 联系方式
|
清空运行期数据:
|
||||||
|
|
||||||
如有相关问题,请 [提交 Issue](https://github.com/KHwang9883/MobileModels/issues)。如有错误或缺漏,欢迎提交 PR。
|
```bash
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
其他平台同名(@KHwang9883),但不一定回复本 repo 相关问题。
|
### 2.5 启动后会自动完成什么
|
||||||
|
|
||||||
|
默认启动后会按配置完成以下动作:
|
||||||
|
|
||||||
|
1. 加载原始数据工作区
|
||||||
|
2. 构建设备索引
|
||||||
|
3. 生成 `dist/device_index.json`
|
||||||
|
4. 生成 `dist/mobilemodels_mysql_seed.sql`
|
||||||
|
5. 按配置决定是否自动装载 MySQL
|
||||||
|
6. 启动 Web 页面与 API
|
||||||
|
7. 启动项目内部的自动同步调度
|
||||||
|
|
||||||
|
## 3. 页面使用
|
||||||
|
|
||||||
|
### 3.1 页面结构
|
||||||
|
|
||||||
|
项目包含两个主页面和一个文档入口:
|
||||||
|
|
||||||
|
- `设备查询`:面向日常查询、联调、设备归类
|
||||||
|
- `数据管理`:面向品牌维护、来源维护、同步维护、索引维护
|
||||||
|
- `使用文档`:面向统一查看项目使用文档
|
||||||
|
|
||||||
|
### 3.2 设备查询页
|
||||||
|
|
||||||
|
设备查询页分为三个 tab。
|
||||||
|
|
||||||
|
#### 设备标识
|
||||||
|
|
||||||
|
适用输入:
|
||||||
|
|
||||||
|
- Android 原始标识,如 `SM-G9980`
|
||||||
|
- iOS 原始标识,如 `iPhone14,2`
|
||||||
|
- HarmonyOS 原始标识,如 `NOH-AL00`
|
||||||
|
|
||||||
|
适合场景:
|
||||||
|
|
||||||
|
- 设备管理
|
||||||
|
- 登录设备展示
|
||||||
|
- 服务端设备归类
|
||||||
|
- 客户端上报值排查
|
||||||
|
|
||||||
|
结果字段使用方式:
|
||||||
|
|
||||||
|
- 设备名称:`device_name`
|
||||||
|
- 品牌展示:优先 `market_brand`,为空再取 `brand`
|
||||||
|
- 厂商归属:优先 `parent_brand`,为空再取 `manufacturer_brand`
|
||||||
|
- 设备类型:`device_type`
|
||||||
|
|
||||||
|
不建议直接用作主展示的字段:
|
||||||
|
|
||||||
|
- `alias_norm`
|
||||||
|
- `source_file`
|
||||||
|
- `source_rank`
|
||||||
|
- `source_weight`
|
||||||
|
- `code`
|
||||||
|
- `code_alias`
|
||||||
|
- `ver_name`
|
||||||
|
|
||||||
|
#### 设备名称
|
||||||
|
|
||||||
|
适用输入:
|
||||||
|
|
||||||
|
- `iPad mini 7`
|
||||||
|
- `iPhone SE 3`
|
||||||
|
- 其他人工可读设备名称
|
||||||
|
|
||||||
|
查询行为:
|
||||||
|
|
||||||
|
1. 先尝试设备名称别名映射
|
||||||
|
2. 未命中时再查 `device_name` / `ver_name`
|
||||||
|
|
||||||
|
适合场景:
|
||||||
|
|
||||||
|
- 运营核对
|
||||||
|
- 人工排查
|
||||||
|
- 已知设备名但不知道原始标识
|
||||||
|
|
||||||
|
#### 标识索引
|
||||||
|
|
||||||
|
适用输入:
|
||||||
|
|
||||||
|
- 设备标识
|
||||||
|
- 常见设备名称
|
||||||
|
|
||||||
|
适合场景:
|
||||||
|
|
||||||
|
- 联调
|
||||||
|
- 索引识别结果比对
|
||||||
|
- 与 MySQL 查询结果做交叉验证
|
||||||
|
|
||||||
|
### 3.3 数据管理页
|
||||||
|
|
||||||
|
数据管理页按左侧导航分为五部分。
|
||||||
|
|
||||||
|
#### 品牌列表
|
||||||
|
|
||||||
|
这里维护:
|
||||||
|
|
||||||
|
- 品牌数、厂商数
|
||||||
|
- 品牌列表
|
||||||
|
- 品牌与厂商关系
|
||||||
|
- 品牌同义词
|
||||||
|
|
||||||
|
适用场景:
|
||||||
|
|
||||||
|
- 品牌口径调整
|
||||||
|
- 厂商归属调整
|
||||||
|
- 品牌展示不符合预期时排查
|
||||||
|
|
||||||
|
#### 手动补录
|
||||||
|
|
||||||
|
这里维护本地覆盖库。
|
||||||
|
|
||||||
|
这里可做的事:
|
||||||
|
|
||||||
|
- 新增独立品牌
|
||||||
|
- 在品牌下补录设备
|
||||||
|
- 编辑或删除本地补录记录
|
||||||
|
|
||||||
|
适用场景:
|
||||||
|
|
||||||
|
- 上游暂未收录的新品牌
|
||||||
|
- 学习机、教育终端、定制设备
|
||||||
|
- 需要立即参与页面查询和 MySQL 查询的补录数据
|
||||||
|
|
||||||
|
使用说明:
|
||||||
|
|
||||||
|
1. 品牌先建在本地覆盖库
|
||||||
|
2. 设备标识填客户端真实上报值
|
||||||
|
3. 保存后自动重建索引和 MySQL seed
|
||||||
|
4. 如果开启 MySQL 自动装载,会继续自动刷新 MySQL
|
||||||
|
5. 本地覆盖库不会被“原始数据同步”覆盖
|
||||||
|
|
||||||
|
#### 数据来源
|
||||||
|
|
||||||
|
这里维护来源优先级。
|
||||||
|
|
||||||
|
使用方式:
|
||||||
|
|
||||||
|
1. 直接拖拽来源顺序
|
||||||
|
2. 越靠前优先级越高
|
||||||
|
3. 保存后影响结果排序
|
||||||
|
|
||||||
|
适用场景:
|
||||||
|
|
||||||
|
- 多来源结果排序不符合预期
|
||||||
|
- 新来源加入后需要重新排序
|
||||||
|
|
||||||
|
#### 原始数据同步
|
||||||
|
|
||||||
|
这里维护:
|
||||||
|
|
||||||
|
- MySQL 自动装载开关
|
||||||
|
- 外部 MySQL 初始化
|
||||||
|
- 每日自动同步时间
|
||||||
|
- 同步状态与任务日志
|
||||||
|
|
||||||
|
适用场景:
|
||||||
|
|
||||||
|
- 上游数据更新后刷新本地数据
|
||||||
|
- 外部 MySQL 需要重建
|
||||||
|
- 调整自动同步策略
|
||||||
|
|
||||||
|
#### 索引数据
|
||||||
|
|
||||||
|
这里用于查看当前索引状态并手动重新加载。
|
||||||
|
|
||||||
|
适用场景:
|
||||||
|
|
||||||
|
- 同步完成后确认索引是否已生效
|
||||||
|
- 页面结果与预期不一致时检查索引版本
|
||||||
|
|
||||||
|
### 3.4 页面调试建议
|
||||||
|
|
||||||
|
如果页面查询结果异常,建议按下面顺序判断:
|
||||||
|
|
||||||
|
1. 先看输入值是否符合页面预期
|
||||||
|
2. 再看页面中的调试信息
|
||||||
|
3. 再看返回 JSON 是否包含预期字段
|
||||||
|
4. 再检查索引或 MySQL 数据是否已刷新
|
||||||
|
|
||||||
|
## 4. 数据接入
|
||||||
|
|
||||||
|
### 4.1 推荐查询入口
|
||||||
|
|
||||||
|
推荐统一查询下面这张主表:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
mobilemodels.mm_device_catalog
|
||||||
|
```
|
||||||
|
|
||||||
|
这张表已经整合了设备标识、设备名称、品牌、厂商、来源和归一化结果。业务侧如果需要查设备信息,优先直接使用这张表,而不是自己拼接多张表或从页面逻辑反推字段。
|
||||||
|
|
||||||
|
### 4.2 推荐查询方式
|
||||||
|
|
||||||
|
主用方式是按 `alias_norm` 等值查询。
|
||||||
|
|
||||||
|
推荐 SQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
model,
|
||||||
|
record_id,
|
||||||
|
alias_norm,
|
||||||
|
device_name,
|
||||||
|
brand,
|
||||||
|
manufacturer_brand,
|
||||||
|
parent_brand,
|
||||||
|
market_brand,
|
||||||
|
device_type,
|
||||||
|
source_file,
|
||||||
|
section,
|
||||||
|
source_rank,
|
||||||
|
source_weight,
|
||||||
|
code,
|
||||||
|
code_alias,
|
||||||
|
ver_name
|
||||||
|
FROM mobilemodels.mm_device_catalog
|
||||||
|
WHERE alias_norm = ?
|
||||||
|
ORDER BY source_rank ASC, record_id ASC
|
||||||
|
LIMIT 20;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 为什么主推 `alias_norm`
|
||||||
|
|
||||||
|
客户端上报的原始 `model_raw` 可能包含大小写、横线、空格、下划线、逗号等差异。为了让相同设备尽可能稳定命中,项目统一把输入值归一化到 `alias_norm`,然后按 `alias_norm` 查询主表。
|
||||||
|
|
||||||
|
这也是页面查询与数据库查询保持一致的关键。
|
||||||
|
|
||||||
|
### 4.4 查询前归一化规则
|
||||||
|
|
||||||
|
归一化规则如下:
|
||||||
|
|
||||||
|
- 先转小写
|
||||||
|
- 只保留数字、英文字母、中文
|
||||||
|
- 去掉空格、横线、下划线、逗号及其他标点
|
||||||
|
|
||||||
|
正则示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/[^0-9a-z\u4e00-\u9fff]+/g
|
||||||
|
```
|
||||||
|
|
||||||
|
JavaScript 示例:
|
||||||
|
|
||||||
|
```js
|
||||||
|
text.toLowerCase().replace(/[^0-9a-z\u4e00-\u9fff]+/g, "")
|
||||||
|
```
|
||||||
|
|
||||||
|
样例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SM-G9980 -> smg9980
|
||||||
|
iPhone14,2 -> iphone142
|
||||||
|
NOH-AL00 -> nohal00
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 结果字段使用建议
|
||||||
|
|
||||||
|
面向设备管理场景,推荐按下面顺序取值:
|
||||||
|
|
||||||
|
- 设备名称:`device_name`
|
||||||
|
- 品牌展示:优先 `market_brand`,为空再取 `brand`
|
||||||
|
- 厂商归属:优先 `parent_brand`,为空再取 `manufacturer_brand`
|
||||||
|
- 设备类型:`device_type`
|
||||||
|
|
||||||
|
下面这些字段通常用于排查、排序或扩展匹配,不建议直接作为主展示字段:
|
||||||
|
|
||||||
|
- `alias_norm`
|
||||||
|
- `source_file`
|
||||||
|
- `source_rank`
|
||||||
|
- `source_weight`
|
||||||
|
- `code`
|
||||||
|
- `code_alias`
|
||||||
|
- `ver_name`
|
||||||
|
|
||||||
|
### 4.6 页面查询与数据库查询的关系
|
||||||
|
|
||||||
|
页面里的 `设备标识` 查询和 `设备名称` 查询,底层都依赖这张主表。因此:
|
||||||
|
|
||||||
|
- 页面结果和第三方直接查库结果应该保持同一口径
|
||||||
|
- 页面里的归一化规则应与服务端或业务接入侧保持一致
|
||||||
|
- 如果页面显示异常,先核对主表数据,再核对前端展示逻辑
|
||||||
|
|
||||||
|
## 5. 数据维护
|
||||||
|
|
||||||
|
### 5.1 数据来源
|
||||||
|
|
||||||
|
项目的数据来源分为两部分:
|
||||||
|
|
||||||
|
- 上游原始数据:`workspace/brands/*.md`
|
||||||
|
- 本地覆盖库:`workspace/local/manual_catalog.json`
|
||||||
|
|
||||||
|
上游原始数据用于同步官方或社区维护的数据,本地覆盖库用于补录当前业务需要但上游暂未收录的品牌和设备。
|
||||||
|
|
||||||
|
### 5.2 数据生成链路
|
||||||
|
|
||||||
|
完整链路如下:
|
||||||
|
|
||||||
|
1. 同步上游原始 markdown
|
||||||
|
2. 解析 `workspace/brands/*.md`
|
||||||
|
3. 合并 `workspace/local/manual_catalog.json`
|
||||||
|
4. 构建 `dist/device_index.json`
|
||||||
|
5. 导出 `dist/mobilemodels_mysql_seed.sql`
|
||||||
|
6. 按配置决定是否自动装载 MySQL
|
||||||
|
|
||||||
|
### 5.3 关键产物
|
||||||
|
|
||||||
|
- 索引文件:`dist/device_index.json`
|
||||||
|
- MySQL seed:`dist/mobilemodels_mysql_seed.sql`
|
||||||
|
|
||||||
|
### 5.4 常见维护动作
|
||||||
|
|
||||||
|
#### 刷新原始数据
|
||||||
|
|
||||||
|
适用于上游数据已变化,需要重新生成索引和 MySQL 数据。
|
||||||
|
|
||||||
|
#### 调整品牌关系
|
||||||
|
|
||||||
|
适用于品牌展示或厂商归属不符合预期的情况。
|
||||||
|
|
||||||
|
#### 手动补录品牌或设备
|
||||||
|
|
||||||
|
适用于上游未收录,但业务需要立即支持的设备。
|
||||||
|
|
||||||
|
维护方式:
|
||||||
|
|
||||||
|
1. 在 `数据管理 -> 手动补录` 中新增品牌或设备
|
||||||
|
2. 保存后自动刷新索引与 MySQL seed
|
||||||
|
3. 如关闭了 MySQL 自动装载,需按需手动初始化或刷新外部 MySQL
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 本地覆盖库不会被上游同步覆盖
|
||||||
|
- 本地补录来源默认优先级更高
|
||||||
|
- 适合维护学习机、教育设备、定制终端
|
||||||
|
|
||||||
|
#### 调整来源顺序
|
||||||
|
|
||||||
|
适用于多个来源的优先级需要重新定义的情况。
|
||||||
|
|
||||||
|
#### 重新加载索引
|
||||||
|
|
||||||
|
适用于索引已更新,但页面还没有使用到最新索引的情况。
|
||||||
|
|
||||||
|
## 6. 配置与运行
|
||||||
|
|
||||||
|
### 6.1 目录说明
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace/ 上游原始数据与补充资料
|
||||||
|
dist/ 索引产物与 MySQL seed
|
||||||
|
docs/ 文档入口与兼容说明
|
||||||
|
sql/ MySQL schema
|
||||||
|
tools/ 构建、同步、导入、服务脚本
|
||||||
|
web/ 页面与静态资源
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 常见环境变量
|
||||||
|
|
||||||
|
- `MYSQL_HOST`
|
||||||
|
- `MYSQL_PORT`
|
||||||
|
- `MYSQL_DATABASE`
|
||||||
|
- `MYSQL_ROOT_USER`
|
||||||
|
- `MYSQL_ROOT_PASSWORD`
|
||||||
|
- `MYSQL_READER_USER`
|
||||||
|
- `MYSQL_READER_PASSWORD`
|
||||||
|
- `MYSQL_AUTO_LOAD`
|
||||||
|
- `SYNC_SCHEDULE_ENABLED`
|
||||||
|
- `SYNC_SCHEDULE_TIME`
|
||||||
|
- `GITHUB_PROXY_PREFIX`
|
||||||
|
- `TZ`
|
||||||
|
|
||||||
|
### 6.3 运行期配置落盘位置
|
||||||
|
|
||||||
|
- MySQL 自动装载:`/data/state/mysql_settings.json`
|
||||||
|
- 自动同步计划:`/data/state/sync_schedule.json`
|
||||||
|
|
||||||
|
### 6.4 运行约定
|
||||||
|
|
||||||
|
- Compose 会优先读取 shell 环境变量和项目根目录 `.env`
|
||||||
|
- 容器启动后会完成索引构建、服务启动,以及按配置决定是否自动装载 MySQL
|
||||||
|
- 自动同步运行在项目容器内部,不依赖 GitHub Actions
|
||||||
|
- 生产环境应替换默认数据库密码
|
||||||
|
|
||||||
|
### 6.5 MySQL 使用建议
|
||||||
|
|
||||||
|
- 生产环境不要继续使用默认密码
|
||||||
|
- 如果接外部 MySQL,先确认 root 账号具备建库建表权限
|
||||||
|
- 如关闭自动装载,需通过数据管理页手动初始化外部 MySQL
|
||||||
|
|
||||||
|
## 7. 排查指南
|
||||||
|
|
||||||
|
### 7.1 查不到设备标识
|
||||||
|
|
||||||
|
按下面顺序排查:
|
||||||
|
|
||||||
|
1. 确认输入的是客户端原始 `model_raw`
|
||||||
|
2. 确认归一化结果是否符合规则
|
||||||
|
3. 查看 `设备标识` 页调试信息和返回 JSON
|
||||||
|
4. 检查主表里是否存在对应 `alias_norm`
|
||||||
|
|
||||||
|
### 7.2 品牌或厂商归属不对
|
||||||
|
|
||||||
|
建议排查:
|
||||||
|
|
||||||
|
1. 查看 `数据管理 -> 品牌列表`
|
||||||
|
2. 核对 `market_brand` 与 `parent_brand`
|
||||||
|
3. 确认是否存在品牌同义词或父级归属未维护的情况
|
||||||
|
|
||||||
|
### 7.3 结果排序不符合预期
|
||||||
|
|
||||||
|
建议排查:
|
||||||
|
|
||||||
|
1. 查看 `数据管理 -> 数据来源`
|
||||||
|
2. 核对 `source_rank`
|
||||||
|
3. 确认结果是否来自预期来源文件
|
||||||
|
|
||||||
|
### 7.4 MySQL 没有刷新
|
||||||
|
|
||||||
|
建议排查:
|
||||||
|
|
||||||
|
1. 查看 `数据管理 -> 原始数据同步`
|
||||||
|
2. 检查 MySQL 自动装载开关
|
||||||
|
3. 查看同步状态与任务日志
|
||||||
|
4. 必要时手动初始化外部 MySQL
|
||||||
|
|
||||||
|
### 7.5 索引结果不对
|
||||||
|
|
||||||
|
建议排查:
|
||||||
|
|
||||||
|
1. 查看 `数据管理 -> 索引数据`
|
||||||
|
2. 检查 `dist/device_index.json` 是否已更新
|
||||||
|
3. 确认页面是否已经重新加载了最新索引
|
||||||
|
|
||||||
|
## 8. 常见问题
|
||||||
|
|
||||||
|
### 8.1 为什么页面结果和数据库结果不一致
|
||||||
|
|
||||||
|
优先检查以下两点:
|
||||||
|
|
||||||
|
- 页面是否使用了最新索引
|
||||||
|
- 数据库是否使用了最新 seed 数据
|
||||||
|
|
||||||
|
如果索引和 MySQL 数据版本不一致,页面和数据库查询结果就可能不同步。
|
||||||
|
|
||||||
|
### 8.2 为什么品牌字段有多个
|
||||||
|
|
||||||
|
项目同时保留原始品牌、展示品牌和父级厂商字段,是为了兼顾原始数据保留、业务展示口径和厂商归属口径。业务展示通常取 `market_brand`,厂商归属通常取 `parent_brand`。
|
||||||
|
|
||||||
|
### 8.3 什么时候用设备名称查询,什么时候用设备标识查询
|
||||||
|
|
||||||
|
- 主流程、服务端接入、设备管理:优先用设备标识查询
|
||||||
|
- 人工排查、运营核对、设备名反查:用设备名称查询
|
||||||
|
|
||||||
|
### 8.4 什么时候需要手动初始化外部 MySQL
|
||||||
|
|
||||||
|
当你使用的是外部 MySQL,且关闭了自动装载,或者数据库结构和数据需要重新初始化时,需要在数据管理页里手动执行初始化。
|
||||||
|
|||||||
+44
-49
@@ -1,60 +1,55 @@
|
|||||||
# Mobile Models
|
# MobileModels
|
||||||
|
|
||||||
[](https://github.com/KHwang9883/MobileModels/issues)
|
The project now uses the repository root as the single runtime entry and can be started directly with Docker Compose.
|
||||||
[](https://github.com/KHwang9883/MobileModels/pulls)
|
|
||||||
[](https://github.com/KHwang9883/MobileModels)
|
|
||||||
[](https://github.com/KHwang9883/MobileModels)
|
|
||||||
[](https://creativecommons.org/licenses/by-nc-sa/4.0/)
|
|
||||||
|
|
||||||
Collecting device names, models and internal codenames.
|
## Run
|
||||||
|
|
||||||
[Issue submission](https://github.com/KHwang9883/MobileModels/issues) and [Pull Requests](https://github.com/KHwang9883/MobileModels/pulls) are welcomed if you find mistakes.
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
Unlisted brands usually not include international models.
|
If you want a local test MySQL together with the app:
|
||||||
|
|
||||||
| Name | Brand | Range |
|
```bash
|
||||||
| :-: | :-: | :-: |
|
docker compose -f docker-compose.yml -f docker-compose.test.yml up --build -d
|
||||||
| [apple_all_en](brands/apple_all_en.md) | Apple | iPhone, iPad, iPod touch, Apple Watch, Apple TV and Apple Vision |
|
```
|
||||||
| [asus_en](brands/asus_en.md) | ASUS | ROG Phone, Zenfone |
|
|
||||||
| [blackshark_en](brands/blackshark_en.md) | Black Shark | All models |
|
|
||||||
| [google](brands/google.md) | Google | Google Pixel phones, tablets & watch |
|
|
||||||
| [honor_global_en](brands/honor_global_en.md) | HONOR | All international models |
|
|
||||||
| [huawei_global_en](brands/huawei_global_en.md) | HUAWEI | HUAWEI Mate, Pura, nova & Y series, MediaPad & MatePad series |
|
|
||||||
| [meizu_en](brands/meizu_en.md) | Meizu | All models |
|
|
||||||
| [mitv_global_en](brands/mitv_global_en.md) | Xiaomi | All international/Indian Xiaomi & Redmi TV models (excluding Chinese models) |
|
|
||||||
| [nothing](brands/nothing.md) | Nothing | All models |
|
|
||||||
| [oneplus_en](brands/oneplus_en.md) | OnePlus | All models |
|
|
||||||
| [oppo_global_en](brands/oppo_global_en.md) | OPPO | International models since 2018 |
|
|
||||||
| [samsung_global_en](brands/samsung_global_en.md) | Samsung | International models since 2019 |
|
|
||||||
| [sony](brands/sony.md) | Sony | All models since 2015 |
|
|
||||||
| [realme_global_en](brands/realme_global_en.md) | realme | All international models |
|
|
||||||
| [vivo_global_en](brands/vivo_global_en.md) | vivo | International models since 2019 |
|
|
||||||
| [xiaomi_en](xiaomi_en.md) | Xiaomi | Xiaomi/Redmi/POCO phones & tablets |
|
|
||||||
|
|
||||||
## Changelog
|
If you need custom MySQL settings, start by copying the env template:
|
||||||
|
|
||||||
[CHANGELOG_en.md](CHANGELOG_en.md)
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
## References
|
Entry pages:
|
||||||
|
|
||||||
- [TENAA](http://zd.taf.org.cn)
|
- `http://127.0.0.1:8123/web/device_query.html`
|
||||||
- [CQCCMS](http://webdata.cqccms.com.cn/webdata/query/CCCCerti.do)
|
- `http://127.0.0.1:8123/web/brand_management.html`
|
||||||
- [MIIT](https://ythzxfw.miit.gov.cn/resultQuery)
|
- `http://127.0.0.1:8123/web/device_query.html?view=docs`
|
||||||
- [China Telecom Tianyi Devices](http://surfing.tydevice.com/)
|
|
||||||
- [Google Play Supported Devices](http://storage.googleapis.com/play_public/supported_devices.html)
|
|
||||||
- [Wi-Fi Alliance](https://www.wi-fi.org)
|
|
||||||
- [Bluetooth Launch Studio](https://launchstudio.bluetooth.com/Listings/Search)
|
|
||||||
- [Xiaomi Firmware Updater](https://xiaomifirmwareupdater.com/)
|
|
||||||
- [Huawei Open Source Release Center](https://consumer.huawei.com/en/opensource/)
|
|
||||||
- [ReaMEIZU](https://reameizu.com/)
|
|
||||||
- [The Apple Wiki](https://theapplewiki.com/)
|
|
||||||
- [ipsw.me](https://ipsw.me)
|
|
||||||
- [XDA Developers](https://www.xda-developers.com)
|
|
||||||
- [Huawei Firmware Database](https://pro-teammt.ru/en/online-firmware-database-ru/)
|
|
||||||
- [XSMS IMEI Database](http://xsms.com.ua/phone/imei/all/1)
|
|
||||||
- [Android Dumps](https://dumps.tadiphone.dev/dumps)
|
|
||||||
- [Lenovo Android タブレット一覧](https://idomizu.dev/archives/20150)
|
|
||||||
|
|
||||||
## License
|
## Structure
|
||||||
|
|
||||||
<a rel="license" href="http://creativecommons.org/licenses/by-nc-sa/4.0/"><img alt="Creative Commons License" style="border-width:0" src="https://i.creativecommons.org/l/by-nc-sa/4.0/88x31.png" /></a><br />This work is licensed under a <a rel="license" href="http://creativecommons.org/licenses/by-nc-sa/4.0/">Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License</a>.
|
```text
|
||||||
|
workspace/ upstream raw data, notes, and history files
|
||||||
|
dist/ build outputs and MySQL seed
|
||||||
|
docs/ project docs
|
||||||
|
sql/ MySQL schema
|
||||||
|
tools/ build, sync, import, and service scripts
|
||||||
|
web/ UI pages and static assets
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `workspace/` stores the source workspace
|
||||||
|
- `docker-compose.yml`, `Dockerfile`, and `tools/` live in the project root
|
||||||
|
- the main compose file targets remote MySQL usage
|
||||||
|
- `docker-compose.test.yml` provides a local MySQL only for testing
|
||||||
|
- generated `dist/device_index.json` and `dist/mobilemodels_mysql_seed.sql` are bind-mounted back to the host project's `dist/` directory
|
||||||
|
- Compose reads shell env vars and project-root `.env` first, then falls back to defaults in `docker-compose.yml`
|
||||||
|
- upstream git sync, index rebuild, and MySQL refresh run inside containers
|
||||||
|
- the project includes its own daily sync scheduler; you can configure the time in the Data Management page or override it via `.env`
|
||||||
|
- GitHub acceleration by URL prefix is supported through `GITHUB_PROXY_PREFIX` or the Data Management page
|
||||||
|
|
||||||
|
More details:
|
||||||
|
|
||||||
|
- [docs/README.md](docs/README.md)
|
||||||
|
- [docs/web-ui.md](docs/web-ui.md)
|
||||||
|
|||||||
Vendored
+195016
File diff suppressed because it is too large
Load Diff
Vendored
+33209
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
|||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.4
|
||||||
|
container_name: mobilemodels-mysql
|
||||||
|
command:
|
||||||
|
- --character-set-server=utf8mb4
|
||||||
|
- --collation-server=utf8mb4_0900_ai_ci
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-mobilemodels_root}
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-mobilemodels}
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
volumes:
|
||||||
|
- mobilemodels_mysql_data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "mysqladmin ping -h127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
|
||||||
|
mobilemodels:
|
||||||
|
environment:
|
||||||
|
MYSQL_HOST: mysql
|
||||||
|
MYSQL_PORT: 3306
|
||||||
|
MYSQL_ROOT_USER: root
|
||||||
|
MYSQL_AUTO_LOAD: 1
|
||||||
|
depends_on:
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mobilemodels_mysql_data:
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
services:
|
||||||
|
mobilemodels:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: mobilemodels-web
|
||||||
|
working_dir: /app
|
||||||
|
environment:
|
||||||
|
MOBILEMODELS_DATA_ROOT: /data
|
||||||
|
TZ: ${TZ:-Asia/Shanghai}
|
||||||
|
MYSQL_HOST: ${MYSQL_HOST:-host.docker.internal}
|
||||||
|
MYSQL_PORT: ${MYSQL_PORT:-3306}
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-mobilemodels}
|
||||||
|
MYSQL_ROOT_USER: ${MYSQL_ROOT_USER:-root}
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-mobilemodels_root}
|
||||||
|
MYSQL_READER_USER: ${MYSQL_READER_USER:-mobilemodels_reader}
|
||||||
|
MYSQL_READER_PASSWORD: ${MYSQL_READER_PASSWORD:-mobilemodels_reader_change_me}
|
||||||
|
MYSQL_AUTO_LOAD: ${MYSQL_AUTO_LOAD:-0}
|
||||||
|
SYNC_SCHEDULE_ENABLED: ${SYNC_SCHEDULE_ENABLED:-0}
|
||||||
|
SYNC_SCHEDULE_TIME: ${SYNC_SCHEDULE_TIME:-03:00}
|
||||||
|
GITHUB_PROXY_PREFIX: ${GITHUB_PROXY_PREFIX:-}
|
||||||
|
command: ["sh", "tools/container_start.sh"]
|
||||||
|
ports:
|
||||||
|
- "8123:8123"
|
||||||
|
volumes:
|
||||||
|
- ./dist:/app/dist
|
||||||
|
- mobilemodels_app_data:/data
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mobilemodels_app_data:
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 项目文档
|
||||||
|
|
||||||
|
交付版文档统一收敛到本目录,便于部署、培训和对外交付。
|
||||||
|
|
||||||
|
## 文档索引
|
||||||
|
|
||||||
|
- [部署与使用说明](web-ui.md)
|
||||||
|
- [MySQL 设计说明](mysql-query-design.md)
|
||||||
|
- [索引构建与设备映射说明](device-mapper.md)
|
||||||
|
|
||||||
|
## 目录说明
|
||||||
|
|
||||||
|
- `web-ui.md`
|
||||||
|
- Docker Compose 启动、页面入口、MySQL 连接和管理能力说明
|
||||||
|
- `mysql-query-design.md`
|
||||||
|
- 主表设计、查询方式与清理策略
|
||||||
|
- `device-mapper.md`
|
||||||
|
- `dist/device_index.json` 构建方式与索引字段说明
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Device Mapper Usage
|
||||||
|
|
||||||
|
This tool builds a cross-platform lookup index from `workspace/brands/*.md`.
|
||||||
|
|
||||||
|
## 1) Build index
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/device_mapper.py build
|
||||||
|
```
|
||||||
|
|
||||||
|
Output file: `dist/device_index.json`
|
||||||
|
|
||||||
|
## 2) Query from command line
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/device_mapper.py find --name 'iPhone14,5' --brand Apple
|
||||||
|
python3 tools/device_mapper.py find --name 'M2102J2SC' --brand Xiaomi
|
||||||
|
python3 tools/device_mapper.py find --name 'L55M5-AD' --brand Xiaomi
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3) JSON structure
|
||||||
|
|
||||||
|
- `records`: normalized device records
|
||||||
|
- `device_name`: standard marketing name
|
||||||
|
- `brand`: normalized brand
|
||||||
|
- `manufacturer_brand`: manufacturer-level brand
|
||||||
|
- `market_brand`: market sub-brand
|
||||||
|
- `device_type`: `phone | tablet | wear | tv | computer | other`
|
||||||
|
- `aliases`: all searchable aliases
|
||||||
|
- `lookup`: normalized alias -> candidate `record.id[]`
|
||||||
|
- `brand_aliases`: normalized brand aliases to filter by app-provided brand
|
||||||
|
- `brand_management`: brand governance metadata
|
||||||
|
|
||||||
|
## 4) App-side integration
|
||||||
|
|
||||||
|
1. Load `dist/device_index.json` into memory.
|
||||||
|
2. Normalize input `name` and optional `brand`.
|
||||||
|
3. Use `lookup[normalized_name]` to fetch candidate records.
|
||||||
|
4. Normalize brand via `brand_management`.
|
||||||
|
5. Filter records by normalized manufacturer or market brand when needed.
|
||||||
|
6. Return first candidate or all candidates.
|
||||||
|
|
||||||
|
Normalization rule:
|
||||||
|
|
||||||
|
- lower-case
|
||||||
|
- keep only `[0-9a-z\u4e00-\u9fff]`
|
||||||
|
- remove spaces, hyphens, underscores and punctuation
|
||||||
|
|
||||||
|
## 5) Device type mapping
|
||||||
|
|
||||||
|
Supported categories:
|
||||||
|
|
||||||
|
- `phone`
|
||||||
|
- `tablet`
|
||||||
|
- `wear`
|
||||||
|
- `tv`
|
||||||
|
- `computer`
|
||||||
|
- `other`
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 项目使用文档入口
|
||||||
|
|
||||||
|
相关说明已统一整理到一份文档中,请查看:
|
||||||
|
|
||||||
|
- [项目使用文档](../README.md)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 项目使用文档入口
|
||||||
|
|
||||||
|
相关说明已统一整理到一份文档中,请查看:
|
||||||
|
|
||||||
|
- [项目使用文档](../README.md)
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
CREATE DATABASE IF NOT EXISTS `mobilemodels`
|
||||||
|
DEFAULT CHARACTER SET utf8mb4
|
||||||
|
DEFAULT COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
DROP DATABASE IF EXISTS `python_services_test`;
|
||||||
|
|
||||||
|
USE `mobilemodels`;
|
||||||
|
|
||||||
|
SET @drop_stmt = (
|
||||||
|
SELECT CASE `TABLE_TYPE`
|
||||||
|
WHEN 'BASE TABLE' THEN 'DROP TABLE `mm_device_catalog`'
|
||||||
|
WHEN 'VIEW' THEN 'DROP VIEW `mm_device_catalog`'
|
||||||
|
ELSE 'DO 0'
|
||||||
|
END
|
||||||
|
FROM `information_schema`.`TABLES`
|
||||||
|
WHERE `TABLE_SCHEMA` = 'mobilemodels' AND `TABLE_NAME` = 'mm_device_catalog'
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
SET @drop_stmt = COALESCE(@drop_stmt, 'DO 0');
|
||||||
|
PREPARE stmt FROM @drop_stmt;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @drop_stmt = (
|
||||||
|
SELECT CASE `TABLE_TYPE`
|
||||||
|
WHEN 'BASE TABLE' THEN 'DROP TABLE `mm_brand_lookup`'
|
||||||
|
WHEN 'VIEW' THEN 'DROP VIEW `mm_brand_lookup`'
|
||||||
|
ELSE 'DO 0'
|
||||||
|
END
|
||||||
|
FROM `information_schema`.`TABLES`
|
||||||
|
WHERE `TABLE_SCHEMA` = 'mobilemodels' AND `TABLE_NAME` = 'mm_brand_lookup'
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
SET @drop_stmt = COALESCE(@drop_stmt, 'DO 0');
|
||||||
|
PREPARE stmt FROM @drop_stmt;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @drop_stmt = (
|
||||||
|
SELECT CASE `TABLE_TYPE`
|
||||||
|
WHEN 'BASE TABLE' THEN 'DROP TABLE `mm_device_lookup`'
|
||||||
|
WHEN 'VIEW' THEN 'DROP VIEW `mm_device_lookup`'
|
||||||
|
ELSE 'DO 0'
|
||||||
|
END
|
||||||
|
FROM `information_schema`.`TABLES`
|
||||||
|
WHERE `TABLE_SCHEMA` = 'mobilemodels' AND `TABLE_NAME` = 'mm_device_lookup'
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
SET @drop_stmt = COALESCE(@drop_stmt, 'DO 0');
|
||||||
|
PREPARE stmt FROM @drop_stmt;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @drop_stmt = (
|
||||||
|
SELECT CASE `TABLE_TYPE`
|
||||||
|
WHEN 'BASE TABLE' THEN 'DROP TABLE `mm_device_record`'
|
||||||
|
WHEN 'VIEW' THEN 'DROP VIEW `mm_device_record`'
|
||||||
|
ELSE 'DO 0'
|
||||||
|
END
|
||||||
|
FROM `information_schema`.`TABLES`
|
||||||
|
WHERE `TABLE_SCHEMA` = 'mobilemodels' AND `TABLE_NAME` = 'mm_device_record'
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
SET @drop_stmt = COALESCE(@drop_stmt, 'DO 0');
|
||||||
|
PREPARE stmt FROM @drop_stmt;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @drop_stmt = (
|
||||||
|
SELECT CASE `TABLE_TYPE`
|
||||||
|
WHEN 'BASE TABLE' THEN 'DROP TABLE `models`'
|
||||||
|
WHEN 'VIEW' THEN 'DROP VIEW `models`'
|
||||||
|
ELSE 'DO 0'
|
||||||
|
END
|
||||||
|
FROM `information_schema`.`TABLES`
|
||||||
|
WHERE `TABLE_SCHEMA` = 'mobilemodels' AND `TABLE_NAME` = 'models'
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
SET @drop_stmt = COALESCE(@drop_stmt, 'DO 0');
|
||||||
|
PREPARE stmt FROM @drop_stmt;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @drop_stmt = (
|
||||||
|
SELECT CASE `TABLE_TYPE`
|
||||||
|
WHEN 'BASE TABLE' THEN 'DROP TABLE `vw_mm_device_lookup`'
|
||||||
|
WHEN 'VIEW' THEN 'DROP VIEW `vw_mm_device_lookup`'
|
||||||
|
ELSE 'DO 0'
|
||||||
|
END
|
||||||
|
FROM `information_schema`.`TABLES`
|
||||||
|
WHERE `TABLE_SCHEMA` = 'mobilemodels' AND `TABLE_NAME` = 'vw_mm_device_lookup'
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
SET @drop_stmt = COALESCE(@drop_stmt, 'DO 0');
|
||||||
|
PREPARE stmt FROM @drop_stmt;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @drop_stmt = (
|
||||||
|
SELECT CASE `TABLE_TYPE`
|
||||||
|
WHEN 'BASE TABLE' THEN 'DROP TABLE `vw_models`'
|
||||||
|
WHEN 'VIEW' THEN 'DROP VIEW `vw_models`'
|
||||||
|
ELSE 'DO 0'
|
||||||
|
END
|
||||||
|
FROM `information_schema`.`TABLES`
|
||||||
|
WHERE `TABLE_SCHEMA` = 'mobilemodels' AND `TABLE_NAME` = 'vw_models'
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
SET @drop_stmt = COALESCE(@drop_stmt, 'DO 0');
|
||||||
|
PREPARE stmt FROM @drop_stmt;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `mm_device_catalog` (
|
||||||
|
`record_id` varchar(64) NOT NULL,
|
||||||
|
`model` varchar(191) NOT NULL,
|
||||||
|
`alias_norm` varchar(191) NOT NULL,
|
||||||
|
`device_name` varchar(255) NOT NULL,
|
||||||
|
`brand` varchar(64) NOT NULL,
|
||||||
|
`manufacturer_brand` varchar(64) NOT NULL,
|
||||||
|
`parent_brand` varchar(64) NOT NULL,
|
||||||
|
`market_brand` varchar(64) NOT NULL,
|
||||||
|
`device_type` enum('phone','tablet','wear','tv','computer','other') NOT NULL,
|
||||||
|
`code` varchar(64) DEFAULT NULL,
|
||||||
|
`code_alias` varchar(255) DEFAULT NULL,
|
||||||
|
`ver_name` text DEFAULT NULL,
|
||||||
|
`source_file` varchar(255) NOT NULL,
|
||||||
|
`section` varchar(255) NOT NULL,
|
||||||
|
`source_rank` int NOT NULL,
|
||||||
|
`source_weight` decimal(6,3) NOT NULL,
|
||||||
|
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
`hash_md5` char(32) GENERATED ALWAYS AS (
|
||||||
|
md5(concat_ws(_utf8mb4'|', `model`, `device_type`, `market_brand`, `manufacturer_brand`, `code`, `code_alias`, `device_name`, `ver_name`))
|
||||||
|
) STORED,
|
||||||
|
`hash_crc` int unsigned GENERATED ALWAYS AS (
|
||||||
|
crc32(concat_ws(_utf8mb4'|', `model`, `device_type`, `market_brand`, `manufacturer_brand`, `code`, `code_alias`, `device_name`, `ver_name`))
|
||||||
|
) STORED,
|
||||||
|
PRIMARY KEY (`record_id`, `model`),
|
||||||
|
KEY `idx_mm_device_catalog_alias_norm` (`alias_norm`, `source_rank`, `record_id`),
|
||||||
|
KEY `idx_mm_device_catalog_model` (`model`),
|
||||||
|
KEY `idx_mm_device_catalog_market_brand` (`market_brand`),
|
||||||
|
KEY `idx_mm_device_catalog_parent_brand` (`parent_brand`),
|
||||||
|
KEY `idx_mm_device_catalog_manufacturer_brand` (`manufacturer_brand`),
|
||||||
|
KEY `idx_mm_device_catalog_device_type` (`device_type`),
|
||||||
|
KEY `idx_mm_device_catalog_code` (`code`),
|
||||||
|
KEY `idx_mm_device_catalog_hash` (`hash_md5`, `hash_crc`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `mm_brand_lookup` (
|
||||||
|
`alias_norm` varchar(191) NOT NULL,
|
||||||
|
`alias_type` enum('manufacturer','parent','market') NOT NULL,
|
||||||
|
`canonical_brand` varchar(64) NOT NULL,
|
||||||
|
`manufacturer_brand` varchar(64) DEFAULT NULL,
|
||||||
|
`parent_brand` varchar(64) DEFAULT NULL,
|
||||||
|
`market_brand` varchar(64) DEFAULT NULL,
|
||||||
|
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`alias_norm`, `alias_type`),
|
||||||
|
KEY `idx_mm_brand_lookup_canonical_brand` (`canonical_brand`),
|
||||||
|
KEY `idx_mm_brand_lookup_manufacturer_brand` (`manufacturer_brand`),
|
||||||
|
KEY `idx_mm_brand_lookup_parent_brand` (`parent_brand`),
|
||||||
|
KEY `idx_mm_brand_lookup_market_brand` (`market_brand`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd /app
|
||||||
|
|
||||||
|
sh tools/init_runtime_data.sh
|
||||||
|
|
||||||
|
python3 tools/device_mapper.py build
|
||||||
|
python3 tools/export_mysql_seed.py
|
||||||
|
|
||||||
|
MYSQL_AUTO_LOAD_EFFECTIVE="$(python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
data_root = Path(os.environ.get("MOBILEMODELS_DATA_ROOT", "/data"))
|
||||||
|
config_path = data_root / "state/mysql_settings.json"
|
||||||
|
raw_default = os.environ.get("MYSQL_AUTO_LOAD", "0").strip().lower()
|
||||||
|
value = raw_default in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if config_path.exists():
|
||||||
|
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
raw = payload.get("auto_load", value) if isinstance(payload, dict) else value
|
||||||
|
value = raw if isinstance(raw, bool) else str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("1" if value else "0")
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [ "$MYSQL_AUTO_LOAD_EFFECTIVE" = "1" ]; then
|
||||||
|
python3 tools/load_mysql_seed.py
|
||||||
|
else
|
||||||
|
echo "Skipping MySQL load because MYSQL_AUTO_LOAD=$MYSQL_AUTO_LOAD_EFFECTIVE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec python3 tools/web_server.py --host 0.0.0.0 --port 8123
|
||||||
@@ -0,0 +1,948 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build and query a cross-platform device mapping index from MobileModels markdown data."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from collections import Counter
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Iterable, List, Optional, Set
|
||||||
|
|
||||||
|
from project_layout import PROJECT_ROOT, WORKSPACE_ROOT
|
||||||
|
|
||||||
|
ENTRY_RE = re.compile(r"^\*\*(.+?)\*\*\s*$")
|
||||||
|
VARIANT_RE = re.compile(r"^\s*((?:`[^`]+`\s*)+):\s*(.+?)\s*$")
|
||||||
|
BACKTICK_RE = re.compile(r"`([^`]+)`")
|
||||||
|
SECTION_RE = re.compile(r"^##\s+(.+?)\s*$")
|
||||||
|
|
||||||
|
|
||||||
|
FILE_BRAND_MAP: Dict[str, str] = {
|
||||||
|
"360shouji": "360",
|
||||||
|
"apple_all": "Apple",
|
||||||
|
"apple_all_en": "Apple",
|
||||||
|
"apple_cn": "Apple",
|
||||||
|
"asus_cn": "ASUS",
|
||||||
|
"asus_en": "ASUS",
|
||||||
|
"blackshark": "Black Shark",
|
||||||
|
"blackshark_en": "Black Shark",
|
||||||
|
"coolpad": "Coolpad",
|
||||||
|
"google": "Google",
|
||||||
|
"honor_cn": "HONOR",
|
||||||
|
"honor_global_en": "HONOR",
|
||||||
|
"huawei_cn": "HUAWEI",
|
||||||
|
"huawei_global_en": "HUAWEI",
|
||||||
|
"lenovo_cn": "Lenovo",
|
||||||
|
"letv": "LeTV",
|
||||||
|
"meizu": "Meizu",
|
||||||
|
"meizu_en": "Meizu",
|
||||||
|
"mitv_cn": "Xiaomi",
|
||||||
|
"mitv_global_en": "Xiaomi",
|
||||||
|
"motorola_cn": "Motorola",
|
||||||
|
"nokia_cn": "Nokia",
|
||||||
|
"nothing": "Nothing",
|
||||||
|
"nubia": "nubia",
|
||||||
|
"oneplus": "OnePlus",
|
||||||
|
"oneplus_en": "OnePlus",
|
||||||
|
"oppo_cn": "OPPO",
|
||||||
|
"oppo_global_en": "OPPO",
|
||||||
|
"realme_cn": "realme",
|
||||||
|
"realme_global_en": "realme",
|
||||||
|
"samsung_cn": "Samsung",
|
||||||
|
"samsung_global_en": "Samsung",
|
||||||
|
"smartisan": "Smartisan",
|
||||||
|
"sony": "Sony",
|
||||||
|
"sony_cn": "Sony",
|
||||||
|
"vivo_cn": "vivo",
|
||||||
|
"vivo_global_en": "vivo",
|
||||||
|
"xiaomi": "Xiaomi",
|
||||||
|
"xiaomi_cn": "Xiaomi",
|
||||||
|
"xiaomi_en": "Xiaomi",
|
||||||
|
"xiaomi-wear": "Xiaomi",
|
||||||
|
"zhixuan": "HUAWEI Smart Selection",
|
||||||
|
"zte_cn": "ZTE",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
FILE_DEFAULT_DEVICE_TYPE: Dict[str, str] = {
|
||||||
|
"mitv_cn": "tv",
|
||||||
|
"mitv_global_en": "tv",
|
||||||
|
"xiaomi-wear": "wear",
|
||||||
|
"apple_all": "phone",
|
||||||
|
"apple_all_en": "phone",
|
||||||
|
"apple_cn": "phone",
|
||||||
|
"google": "phone",
|
||||||
|
"honor_cn": "phone",
|
||||||
|
"honor_global_en": "phone",
|
||||||
|
"huawei_cn": "phone",
|
||||||
|
"huawei_global_en": "phone",
|
||||||
|
"xiaomi": "phone",
|
||||||
|
"xiaomi_cn": "phone",
|
||||||
|
"xiaomi_en": "phone",
|
||||||
|
"zhixuan": "phone",
|
||||||
|
}
|
||||||
|
|
||||||
|
MANUAL_SOURCE_FILE = "local/manual_catalog.json"
|
||||||
|
|
||||||
|
|
||||||
|
BRAND_ALIASES: Dict[str, List[str]] = {
|
||||||
|
"360": ["360", "360手机", "奇酷", "qiku"],
|
||||||
|
"Apple": ["apple", "苹果", "iphone", "ipad", "ipod"],
|
||||||
|
"ASUS": ["asus", "华硕", "rog", "zenfone"],
|
||||||
|
"Black Shark": ["black shark", "blackshark", "黑鲨"],
|
||||||
|
"Coolpad": ["coolpad", "酷派"],
|
||||||
|
"Google": ["google", "pixel"],
|
||||||
|
"HONOR": ["honor", "荣耀"],
|
||||||
|
"HUAWEI": ["huawei", "华为"],
|
||||||
|
"HUAWEI Smart Selection": ["华为智选", "zhixuan", "umagic", "wiko", "hi nova", "nzone"],
|
||||||
|
"Lenovo": ["lenovo", "联想", "zuk", "拯救者"],
|
||||||
|
"LeTV": ["letv", "乐视"],
|
||||||
|
"Meizu": ["meizu", "魅族"],
|
||||||
|
"Motorola": ["motorola", "摩托罗拉", "moto"],
|
||||||
|
"Nokia": ["nokia", "诺基亚"],
|
||||||
|
"Nothing": ["nothing", "cmf"],
|
||||||
|
"nubia": ["nubia", "努比亚", "红魔", "redmagic"],
|
||||||
|
"iQOO": ["iqoo", "i qoo", "艾酷"],
|
||||||
|
"OnePlus": ["oneplus", "一加"],
|
||||||
|
"OPPO": ["oppo"],
|
||||||
|
"POCO": ["poco"],
|
||||||
|
"Redmi": ["redmi", "红米", "hongmi"],
|
||||||
|
"realme": ["realme", "真我"],
|
||||||
|
"Samsung": ["samsung", "三星", "galaxy"],
|
||||||
|
"Smartisan": ["smartisan", "锤子", "坚果"],
|
||||||
|
"Sony": ["sony", "索尼", "xperia"],
|
||||||
|
"vivo": ["vivo"],
|
||||||
|
"Xiaomi": ["xiaomi", "小米", "mi", "米家", "mipad"],
|
||||||
|
"ZTE": ["zte", "中兴"],
|
||||||
|
}
|
||||||
|
|
||||||
|
MANUFACTURER_PARENT_BRAND: Dict[str, str] = {
|
||||||
|
"Black Shark": "Xiaomi",
|
||||||
|
"HUAWEI Smart Selection": "HUAWEI",
|
||||||
|
"Motorola": "Lenovo",
|
||||||
|
"iQOO": "vivo",
|
||||||
|
"POCO": "Xiaomi",
|
||||||
|
"Redmi": "Xiaomi",
|
||||||
|
"OnePlus": "OPPO",
|
||||||
|
"realme": "OPPO",
|
||||||
|
"nubia": "ZTE",
|
||||||
|
}
|
||||||
|
|
||||||
|
MARKET_BRAND_ALIASES: Dict[str, List[str]] = {
|
||||||
|
"iQOO": ["iqoo", "i qoo", "艾酷"],
|
||||||
|
"POCO": ["poco"],
|
||||||
|
"Redmi": ["redmi", "红米", "hongmi"],
|
||||||
|
"Xiaomi": ["xiaomi", "小米", "mi", "mipad", "米家"],
|
||||||
|
}
|
||||||
|
|
||||||
|
MARKET_BRAND_TO_MANUFACTURER: Dict[str, str] = {
|
||||||
|
"iQOO": "vivo",
|
||||||
|
"POCO": "Xiaomi",
|
||||||
|
"Redmi": "Xiaomi",
|
||||||
|
"Xiaomi": "Xiaomi",
|
||||||
|
}
|
||||||
|
|
||||||
|
TV_KEYWORDS = [
|
||||||
|
"tv",
|
||||||
|
"电视",
|
||||||
|
"智慧屏",
|
||||||
|
"smart tv",
|
||||||
|
"机顶盒",
|
||||||
|
"tv box",
|
||||||
|
"stick",
|
||||||
|
"dongle",
|
||||||
|
]
|
||||||
|
TABLET_KEYWORDS = [
|
||||||
|
"ipad",
|
||||||
|
"tablet",
|
||||||
|
"tab",
|
||||||
|
"pad",
|
||||||
|
"平板",
|
||||||
|
"matepad",
|
||||||
|
]
|
||||||
|
WEAR_KEYWORDS = [
|
||||||
|
"watch",
|
||||||
|
"smartwatch",
|
||||||
|
"手表",
|
||||||
|
"手环",
|
||||||
|
"band",
|
||||||
|
"wear",
|
||||||
|
"wearable",
|
||||||
|
"buds",
|
||||||
|
"earbuds",
|
||||||
|
"耳机",
|
||||||
|
"tws",
|
||||||
|
"eyewear",
|
||||||
|
"glasses",
|
||||||
|
"眼镜",
|
||||||
|
]
|
||||||
|
COMPUTER_KEYWORDS = [
|
||||||
|
"matebook",
|
||||||
|
"macbook",
|
||||||
|
"笔记本",
|
||||||
|
"电脑",
|
||||||
|
"laptop",
|
||||||
|
"notebook",
|
||||||
|
"desktop",
|
||||||
|
"workstation",
|
||||||
|
]
|
||||||
|
OTHER_KEYWORDS = [
|
||||||
|
"vision",
|
||||||
|
"vr",
|
||||||
|
"ipod",
|
||||||
|
"airpods",
|
||||||
|
]
|
||||||
|
PHONE_KEYWORDS = [
|
||||||
|
"iphone",
|
||||||
|
"phone",
|
||||||
|
"手机",
|
||||||
|
"galaxy",
|
||||||
|
"pixel",
|
||||||
|
"xiaomi",
|
||||||
|
"redmi",
|
||||||
|
"poco",
|
||||||
|
"honor",
|
||||||
|
"huawei",
|
||||||
|
"mate",
|
||||||
|
"nova",
|
||||||
|
"oppo",
|
||||||
|
"vivo",
|
||||||
|
"realme",
|
||||||
|
"oneplus",
|
||||||
|
"nokia",
|
||||||
|
"nubia",
|
||||||
|
"meizu",
|
||||||
|
"lenovo",
|
||||||
|
"motorola",
|
||||||
|
"zte",
|
||||||
|
"smartisan",
|
||||||
|
"zenfone",
|
||||||
|
"rog",
|
||||||
|
"麦芒",
|
||||||
|
"畅享",
|
||||||
|
"优畅享",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeviceRecord:
|
||||||
|
id: str
|
||||||
|
device_name: str
|
||||||
|
brand: str
|
||||||
|
manufacturer_brand: str
|
||||||
|
parent_brand: str
|
||||||
|
market_brand: str
|
||||||
|
device_type: str
|
||||||
|
aliases: List[str]
|
||||||
|
source_file: str
|
||||||
|
section: str
|
||||||
|
|
||||||
|
|
||||||
|
MANUAL_BRAND_ALIAS_OVERRIDES: Dict[str, List[str]] = {}
|
||||||
|
MANUAL_PARENT_BRAND_OVERRIDES: Dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_text(text: str) -> str:
|
||||||
|
return re.sub(r"[^0-9a-z\u4e00-\u9fff]+", "", text.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_brand(file_stem: str) -> str:
|
||||||
|
return FILE_BRAND_MAP.get(file_stem, file_stem)
|
||||||
|
|
||||||
|
|
||||||
|
def brand_aliases(brand: str) -> List[str]:
|
||||||
|
aliases = set(BRAND_ALIASES.get(brand, []))
|
||||||
|
aliases.update(MANUAL_BRAND_ALIAS_OVERRIDES.get(brand, []))
|
||||||
|
aliases.add(brand)
|
||||||
|
return sorted(aliases)
|
||||||
|
|
||||||
|
|
||||||
|
def has_keyword(text: str, keywords: Iterable[str]) -> bool:
|
||||||
|
norm_text = re.sub(r"[^0-9a-z\u4e00-\u9fff]+", " ", text.lower())
|
||||||
|
norm_text = " ".join(norm_text.split())
|
||||||
|
for kw in keywords:
|
||||||
|
kw_norm = re.sub(r"[^0-9a-z\u4e00-\u9fff]+", " ", kw.lower())
|
||||||
|
kw_norm = " ".join(kw_norm.split())
|
||||||
|
if kw_norm and kw_norm in norm_text:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_parent_brand(manufacturer_brand: str) -> str:
|
||||||
|
if manufacturer_brand in MANUAL_PARENT_BRAND_OVERRIDES:
|
||||||
|
return MANUAL_PARENT_BRAND_OVERRIDES[manufacturer_brand]
|
||||||
|
return MANUFACTURER_PARENT_BRAND.get(manufacturer_brand, manufacturer_brand)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_manual_overrides() -> None:
|
||||||
|
MANUAL_BRAND_ALIAS_OVERRIDES.clear()
|
||||||
|
MANUAL_PARENT_BRAND_OVERRIDES.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_alias_list(*groups: object) -> List[str]:
|
||||||
|
aliases: List[str] = []
|
||||||
|
seen: Set[str] = set()
|
||||||
|
for group in groups:
|
||||||
|
if group is None:
|
||||||
|
continue
|
||||||
|
items = group if isinstance(group, (list, tuple, set)) else [group]
|
||||||
|
for item in items:
|
||||||
|
text = str(item or "").strip()
|
||||||
|
key = normalize_text(text)
|
||||||
|
if not text or not key or key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
aliases.append(text)
|
||||||
|
return aliases
|
||||||
|
|
||||||
|
|
||||||
|
def is_preferred_cn_source_file(source_file: str) -> bool:
|
||||||
|
source = str(source_file or "").strip().lower()
|
||||||
|
if not source:
|
||||||
|
return False
|
||||||
|
if source == MANUAL_SOURCE_FILE:
|
||||||
|
return True
|
||||||
|
return not (source.endswith("_en.md") or source.endswith("_global_en.md"))
|
||||||
|
|
||||||
|
|
||||||
|
def first_preferred_match(records: List[DeviceRecord]) -> List[DeviceRecord]:
|
||||||
|
if not records:
|
||||||
|
return []
|
||||||
|
cn_records = [record for record in records if is_preferred_cn_source_file(record.source_file)]
|
||||||
|
return [(cn_records or records)[0]]
|
||||||
|
|
||||||
|
|
||||||
|
def load_manual_catalog(repo_root: Path) -> dict[str, object]:
|
||||||
|
path = repo_root / MANUAL_SOURCE_FILE
|
||||||
|
if not path.exists():
|
||||||
|
return {"brands": [], "devices": []}
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise RuntimeError(f"{MANUAL_SOURCE_FILE} 必须是 JSON 对象。")
|
||||||
|
brands = payload.get("brands")
|
||||||
|
devices = payload.get("devices")
|
||||||
|
return {
|
||||||
|
"brands": brands if isinstance(brands, list) else [],
|
||||||
|
"devices": devices if isinstance(devices, list) else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_manual_brand_overrides(manual_catalog: dict[str, object]) -> dict[str, str]:
|
||||||
|
reset_manual_overrides()
|
||||||
|
brand_parent_map: dict[str, str] = {}
|
||||||
|
for raw_brand in manual_catalog.get("brands", []):
|
||||||
|
if not isinstance(raw_brand, dict):
|
||||||
|
continue
|
||||||
|
brand_name = str(raw_brand.get("name") or "").strip()
|
||||||
|
if not brand_name:
|
||||||
|
continue
|
||||||
|
aliases = normalize_alias_list(brand_name, raw_brand.get("aliases"))
|
||||||
|
parent_brand = str(raw_brand.get("parent_brand") or brand_name).strip() or brand_name
|
||||||
|
MANUAL_BRAND_ALIAS_OVERRIDES[brand_name] = aliases
|
||||||
|
MANUAL_PARENT_BRAND_OVERRIDES[brand_name] = parent_brand
|
||||||
|
brand_parent_map[brand_name] = parent_brand
|
||||||
|
return brand_parent_map
|
||||||
|
|
||||||
|
|
||||||
|
def parse_manual_catalog(repo_root: Path, manual_catalog: dict[str, object]) -> List[DeviceRecord]:
|
||||||
|
brand_parent_map = apply_manual_brand_overrides(manual_catalog)
|
||||||
|
records: List[DeviceRecord] = []
|
||||||
|
|
||||||
|
for raw_device in manual_catalog.get("devices", []):
|
||||||
|
if not isinstance(raw_device, dict):
|
||||||
|
continue
|
||||||
|
brand = str(raw_device.get("brand") or "").strip()
|
||||||
|
device_name = str(raw_device.get("device_name") or "").strip()
|
||||||
|
if not brand or not device_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
aliases = normalize_alias_list(
|
||||||
|
raw_device.get("models"),
|
||||||
|
device_name,
|
||||||
|
raw_device.get("aliases"),
|
||||||
|
)
|
||||||
|
if not aliases:
|
||||||
|
continue
|
||||||
|
|
||||||
|
device_type = str(raw_device.get("device_type") or "").strip() or "other"
|
||||||
|
section = str(raw_device.get("section") or "手动补录").strip() or "手动补录"
|
||||||
|
record_id = str(raw_device.get("id") or "").strip() or f"manual:{normalize_text(brand)}:{normalize_text(device_name)}"
|
||||||
|
parent_brand = brand_parent_map.get(brand, str(raw_device.get("parent_brand") or brand).strip() or brand)
|
||||||
|
|
||||||
|
records.append(
|
||||||
|
DeviceRecord(
|
||||||
|
id=record_id,
|
||||||
|
device_name=device_name,
|
||||||
|
brand=brand,
|
||||||
|
manufacturer_brand=brand,
|
||||||
|
parent_brand=parent_brand,
|
||||||
|
market_brand=brand,
|
||||||
|
device_type=device_type,
|
||||||
|
aliases=aliases,
|
||||||
|
source_file=MANUAL_SOURCE_FILE,
|
||||||
|
section=section,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def infer_market_brand(
|
||||||
|
manufacturer_brand: str,
|
||||||
|
device_name: str,
|
||||||
|
section: str,
|
||||||
|
aliases: Iterable[str],
|
||||||
|
) -> str:
|
||||||
|
corpus = normalize_text(" ".join([device_name, section, *aliases]))
|
||||||
|
|
||||||
|
if manufacturer_brand == "Xiaomi":
|
||||||
|
poco_keys = [normalize_text(v) for v in MARKET_BRAND_ALIASES["POCO"]]
|
||||||
|
redmi_keys = [normalize_text(v) for v in MARKET_BRAND_ALIASES["Redmi"]]
|
||||||
|
if any(key and key in corpus for key in poco_keys):
|
||||||
|
return "POCO"
|
||||||
|
if any(key and key in corpus for key in redmi_keys):
|
||||||
|
return "Redmi"
|
||||||
|
return "Xiaomi"
|
||||||
|
|
||||||
|
if manufacturer_brand == "vivo":
|
||||||
|
iqoo_keys = [normalize_text(v) for v in MARKET_BRAND_ALIASES["iQOO"]]
|
||||||
|
if any(key and key in corpus for key in iqoo_keys):
|
||||||
|
return "iQOO"
|
||||||
|
return "vivo"
|
||||||
|
|
||||||
|
return manufacturer_brand
|
||||||
|
|
||||||
|
|
||||||
|
def infer_device_type(
|
||||||
|
device_name: str,
|
||||||
|
section: str,
|
||||||
|
source_file: str,
|
||||||
|
aliases: Iterable[str],
|
||||||
|
default_type: str,
|
||||||
|
) -> str:
|
||||||
|
corpus = " ".join([device_name, section, *aliases, source_file])
|
||||||
|
|
||||||
|
if has_keyword(corpus, TV_KEYWORDS):
|
||||||
|
return "tv"
|
||||||
|
if has_keyword(corpus, TABLET_KEYWORDS):
|
||||||
|
return "tablet"
|
||||||
|
if has_keyword(corpus, WEAR_KEYWORDS):
|
||||||
|
return "wear"
|
||||||
|
if has_keyword(corpus, COMPUTER_KEYWORDS):
|
||||||
|
return "computer"
|
||||||
|
if has_keyword(corpus, OTHER_KEYWORDS):
|
||||||
|
return "other"
|
||||||
|
if has_keyword(corpus, PHONE_KEYWORDS):
|
||||||
|
return "phone"
|
||||||
|
return default_type or "other"
|
||||||
|
|
||||||
|
|
||||||
|
def clean_entry_title(raw_title: str) -> str:
|
||||||
|
title = raw_title.strip()
|
||||||
|
if title.endswith(":"):
|
||||||
|
title = title[:-1].strip()
|
||||||
|
|
||||||
|
# remove leading tag like: [`X1`] or [X1]
|
||||||
|
title = re.sub(r"^\[[^\]]+\]\s*", "", title)
|
||||||
|
|
||||||
|
# remove one or more trailing codenames like: (`foo`) (`bar`)
|
||||||
|
title = re.sub(r"(?:\s*\(\s*`[^`]+`\s*\))+\s*$", "", title)
|
||||||
|
title = re.sub(r"\s*\((?:codename|代号)[^)]*\)\s*$", "", title, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
# strip markdown links while keeping text: [Foo](url) -> Foo
|
||||||
|
title = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", title)
|
||||||
|
|
||||||
|
title = " ".join(title.split())
|
||||||
|
return title
|
||||||
|
|
||||||
|
|
||||||
|
def extract_codes(text: str) -> List[str]:
|
||||||
|
return [code.strip() for code in BACKTICK_RE.findall(text) if code.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def infer_base_variant_name(variant_name: str, entry_title: str) -> Optional[str]:
|
||||||
|
base = re.split(r"\s+/\s+", variant_name.strip(), maxsplit=1)[0].strip()
|
||||||
|
if not base:
|
||||||
|
return None
|
||||||
|
|
||||||
|
base = re.sub(r"\s*(?:国行版|国内版|中国版|印度版|欧洲版|国际版|北美版|日本版|韩国版|港版|台版|海外版)\s*$", "", base)
|
||||||
|
base = re.sub(
|
||||||
|
r"\s+(?:China|Chinese|India|Europe|European|Global|International|North America|North American|Japan|Korea|Hong Kong|Taiwan|US|USA|T-Mobile|Verizon|AT&T|SIM Free|SoftBank)\s*$",
|
||||||
|
"",
|
||||||
|
base,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
base = " ".join(base.split())
|
||||||
|
if not base or normalize_text(base) not in normalize_text(entry_title):
|
||||||
|
return None
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def split_variant_groups(entry_title: str, title_codes: Iterable[str], variants: list[tuple[list[str], str]]) -> list[tuple[str, Set[str]]]:
|
||||||
|
groups: dict[str, Set[str]] = {}
|
||||||
|
for variant_codes, variant_name in variants:
|
||||||
|
base_name = infer_base_variant_name(variant_name, entry_title)
|
||||||
|
if not base_name:
|
||||||
|
return []
|
||||||
|
aliases = groups.setdefault(base_name, set(title_codes))
|
||||||
|
aliases.add(base_name)
|
||||||
|
aliases.add(variant_name)
|
||||||
|
aliases.update(variant_codes)
|
||||||
|
|
||||||
|
if len(groups) < 2:
|
||||||
|
return []
|
||||||
|
return list(groups.items())
|
||||||
|
|
||||||
|
|
||||||
|
def parse_brand_file(path: Path) -> List[DeviceRecord]:
|
||||||
|
file_stem = path.stem
|
||||||
|
brand = canonical_brand(file_stem)
|
||||||
|
default_type = FILE_DEFAULT_DEVICE_TYPE.get(file_stem, "phone")
|
||||||
|
|
||||||
|
records: List[DeviceRecord] = []
|
||||||
|
lines = path.read_text(encoding="utf-8").splitlines()
|
||||||
|
|
||||||
|
section = ""
|
||||||
|
current_title = ""
|
||||||
|
current_title_codes: List[str] = []
|
||||||
|
current_aliases: Set[str] = set()
|
||||||
|
current_variants: list[tuple[list[str], str]] = []
|
||||||
|
|
||||||
|
def flush_current() -> None:
|
||||||
|
nonlocal current_title, current_title_codes, current_aliases, current_variants
|
||||||
|
if not current_title:
|
||||||
|
return
|
||||||
|
|
||||||
|
split_groups = split_variant_groups(current_title, current_title_codes, current_variants)
|
||||||
|
record_groups = split_groups or [(current_title, current_aliases)]
|
||||||
|
for device_name, raw_aliases in record_groups:
|
||||||
|
aliases = sorted({alias.strip() for alias in raw_aliases if alias.strip()})
|
||||||
|
record_id = f"{file_stem}:{len(records) + 1}"
|
||||||
|
device_type = infer_device_type(
|
||||||
|
device_name=device_name,
|
||||||
|
section=section,
|
||||||
|
source_file=path.name,
|
||||||
|
aliases=aliases,
|
||||||
|
default_type=default_type,
|
||||||
|
)
|
||||||
|
records.append(
|
||||||
|
DeviceRecord(
|
||||||
|
id=record_id,
|
||||||
|
device_name=device_name,
|
||||||
|
brand=brand,
|
||||||
|
manufacturer_brand=brand,
|
||||||
|
parent_brand=resolve_parent_brand(brand),
|
||||||
|
market_brand=infer_market_brand(
|
||||||
|
manufacturer_brand=brand,
|
||||||
|
device_name=device_name,
|
||||||
|
section=section,
|
||||||
|
aliases=aliases,
|
||||||
|
),
|
||||||
|
device_type=device_type,
|
||||||
|
aliases=aliases,
|
||||||
|
source_file=f"brands/{path.name}",
|
||||||
|
section=section,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
current_title = ""
|
||||||
|
current_title_codes = []
|
||||||
|
current_aliases = set()
|
||||||
|
current_variants = []
|
||||||
|
|
||||||
|
for raw in lines:
|
||||||
|
line = raw.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
section_match = SECTION_RE.match(line)
|
||||||
|
if section_match:
|
||||||
|
flush_current()
|
||||||
|
section = section_match.group(1).strip()
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry_match = ENTRY_RE.match(line)
|
||||||
|
if entry_match:
|
||||||
|
flush_current()
|
||||||
|
raw_title = entry_match.group(1).strip()
|
||||||
|
current_title = clean_entry_title(raw_title)
|
||||||
|
current_title_codes = extract_codes(raw_title)
|
||||||
|
current_aliases = set(current_title_codes)
|
||||||
|
current_aliases.add(current_title)
|
||||||
|
current_variants = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not current_title:
|
||||||
|
continue
|
||||||
|
|
||||||
|
variant_match = VARIANT_RE.match(line)
|
||||||
|
if variant_match:
|
||||||
|
variant_codes = extract_codes(variant_match.group(1))
|
||||||
|
variant_name = variant_match.group(2).strip()
|
||||||
|
current_variants.append((variant_codes, variant_name))
|
||||||
|
current_aliases.update(variant_codes)
|
||||||
|
current_aliases.add(variant_name)
|
||||||
|
|
||||||
|
flush_current()
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceMapper:
|
||||||
|
def __init__(self, records: List[DeviceRecord]) -> None:
|
||||||
|
self.records = records
|
||||||
|
self.records_by_id = {record.id: record for record in records}
|
||||||
|
self.manufacturer_alias_lookup: Dict[str, str] = {}
|
||||||
|
self.parent_alias_lookup: Dict[str, str] = {}
|
||||||
|
self.market_alias_lookup: Dict[str, str] = {}
|
||||||
|
self.parent_to_children: Dict[str, Set[str]] = {}
|
||||||
|
|
||||||
|
self.alias_index: Dict[str, Set[str]] = {}
|
||||||
|
for record in records:
|
||||||
|
for alias in record.aliases:
|
||||||
|
key = normalize_text(alias)
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
self.alias_index.setdefault(key, set()).add(record.id)
|
||||||
|
|
||||||
|
manufacturers = sorted({record.manufacturer_brand for record in records})
|
||||||
|
parents = sorted({record.parent_brand for record in records})
|
||||||
|
for brand in manufacturers:
|
||||||
|
for alias in brand_aliases(brand):
|
||||||
|
key = normalize_text(alias)
|
||||||
|
if key:
|
||||||
|
self.manufacturer_alias_lookup[key] = brand
|
||||||
|
|
||||||
|
for parent in parents:
|
||||||
|
for alias in brand_aliases(parent):
|
||||||
|
key = normalize_text(alias)
|
||||||
|
if key:
|
||||||
|
self.parent_alias_lookup[key] = parent
|
||||||
|
|
||||||
|
for manufacturer in manufacturers:
|
||||||
|
parent = resolve_parent_brand(manufacturer)
|
||||||
|
self.parent_to_children.setdefault(parent, set()).add(manufacturer)
|
||||||
|
|
||||||
|
for market_brand, aliases in MARKET_BRAND_ALIASES.items():
|
||||||
|
for alias in set([market_brand, *aliases]):
|
||||||
|
key = normalize_text(alias)
|
||||||
|
if key:
|
||||||
|
self.market_alias_lookup[key] = market_brand
|
||||||
|
|
||||||
|
def _parse_brand_filter(self, input_brand: Optional[str]) -> Dict[str, Optional[str]]:
|
||||||
|
if not input_brand:
|
||||||
|
return {
|
||||||
|
"parent_brand": None,
|
||||||
|
"manufacturer_brand": None,
|
||||||
|
"market_brand": None,
|
||||||
|
"source": "none",
|
||||||
|
}
|
||||||
|
|
||||||
|
input_norm = normalize_text(input_brand)
|
||||||
|
if not input_norm:
|
||||||
|
return {
|
||||||
|
"parent_brand": None,
|
||||||
|
"manufacturer_brand": None,
|
||||||
|
"market_brand": None,
|
||||||
|
"source": "none",
|
||||||
|
}
|
||||||
|
|
||||||
|
if input_norm in self.market_alias_lookup:
|
||||||
|
market_brand = self.market_alias_lookup[input_norm]
|
||||||
|
manufacturer_brand = MARKET_BRAND_TO_MANUFACTURER.get(market_brand, market_brand)
|
||||||
|
parent_brand = resolve_parent_brand(manufacturer_brand)
|
||||||
|
if market_brand == "Xiaomi":
|
||||||
|
return {
|
||||||
|
"parent_brand": parent_brand,
|
||||||
|
"manufacturer_brand": manufacturer_brand,
|
||||||
|
"market_brand": None,
|
||||||
|
"source": "manufacturer_alias_from_market",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"parent_brand": parent_brand,
|
||||||
|
"manufacturer_brand": manufacturer_brand,
|
||||||
|
"market_brand": market_brand,
|
||||||
|
"source": "market_alias_exact",
|
||||||
|
}
|
||||||
|
|
||||||
|
if input_norm in self.manufacturer_alias_lookup:
|
||||||
|
manufacturer_brand = self.manufacturer_alias_lookup[input_norm]
|
||||||
|
parent_brand = resolve_parent_brand(manufacturer_brand)
|
||||||
|
children = self.parent_to_children.get(manufacturer_brand, set())
|
||||||
|
if manufacturer_brand == parent_brand and len(children) > 1:
|
||||||
|
return {
|
||||||
|
"parent_brand": parent_brand,
|
||||||
|
"manufacturer_brand": None,
|
||||||
|
"market_brand": None,
|
||||||
|
"source": "parent_alias_exact",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"parent_brand": parent_brand,
|
||||||
|
"manufacturer_brand": manufacturer_brand,
|
||||||
|
"market_brand": None,
|
||||||
|
"source": "manufacturer_alias_exact",
|
||||||
|
}
|
||||||
|
|
||||||
|
if input_norm in self.parent_alias_lookup:
|
||||||
|
parent_brand = self.parent_alias_lookup[input_norm]
|
||||||
|
return {
|
||||||
|
"parent_brand": parent_brand,
|
||||||
|
"manufacturer_brand": None,
|
||||||
|
"market_brand": None,
|
||||||
|
"source": "parent_alias_exact",
|
||||||
|
}
|
||||||
|
|
||||||
|
for alias_norm, market_brand in self.market_alias_lookup.items():
|
||||||
|
if alias_norm and alias_norm in input_norm:
|
||||||
|
manufacturer_brand = MARKET_BRAND_TO_MANUFACTURER.get(market_brand, market_brand)
|
||||||
|
return {
|
||||||
|
"parent_brand": resolve_parent_brand(manufacturer_brand),
|
||||||
|
"manufacturer_brand": manufacturer_brand,
|
||||||
|
"market_brand": market_brand,
|
||||||
|
"source": "market_alias_contains",
|
||||||
|
}
|
||||||
|
|
||||||
|
for alias_norm, manufacturer_brand in self.manufacturer_alias_lookup.items():
|
||||||
|
if alias_norm and alias_norm in input_norm:
|
||||||
|
parent_brand = resolve_parent_brand(manufacturer_brand)
|
||||||
|
children = self.parent_to_children.get(manufacturer_brand, set())
|
||||||
|
if manufacturer_brand == parent_brand and len(children) > 1:
|
||||||
|
return {
|
||||||
|
"parent_brand": parent_brand,
|
||||||
|
"manufacturer_brand": None,
|
||||||
|
"market_brand": None,
|
||||||
|
"source": "parent_alias_contains",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"parent_brand": parent_brand,
|
||||||
|
"manufacturer_brand": manufacturer_brand,
|
||||||
|
"market_brand": None,
|
||||||
|
"source": "manufacturer_alias_contains",
|
||||||
|
}
|
||||||
|
|
||||||
|
for alias_norm, parent_brand in self.parent_alias_lookup.items():
|
||||||
|
if alias_norm and alias_norm in input_norm:
|
||||||
|
return {
|
||||||
|
"parent_brand": parent_brand,
|
||||||
|
"manufacturer_brand": None,
|
||||||
|
"market_brand": None,
|
||||||
|
"source": "parent_alias_contains",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"parent_brand": None,
|
||||||
|
"manufacturer_brand": None,
|
||||||
|
"market_brand": None,
|
||||||
|
"source": "none",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _brand_match(
|
||||||
|
brand_filter: Dict[str, Optional[str]],
|
||||||
|
record: DeviceRecord,
|
||||||
|
) -> bool:
|
||||||
|
parent = brand_filter.get("parent_brand")
|
||||||
|
manufacturer = brand_filter.get("manufacturer_brand")
|
||||||
|
market = brand_filter.get("market_brand")
|
||||||
|
|
||||||
|
if parent and record.parent_brand != parent:
|
||||||
|
return False
|
||||||
|
if manufacturer and record.manufacturer_brand != manufacturer:
|
||||||
|
return False
|
||||||
|
if market and record.market_brand != market:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def find(self, name: str, brand: Optional[str] = None, limit: int = 5) -> Dict[str, object]:
|
||||||
|
query = normalize_text(name)
|
||||||
|
if not query:
|
||||||
|
return {
|
||||||
|
"matched": False,
|
||||||
|
"reason": "Empty device name.",
|
||||||
|
"query_name": name,
|
||||||
|
"query_brand": brand,
|
||||||
|
"candidates": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate_ids = list(self.alias_index.get(query, set()))
|
||||||
|
matched_records = [self.records_by_id[rid] for rid in candidate_ids]
|
||||||
|
brand_filter = self._parse_brand_filter(brand)
|
||||||
|
|
||||||
|
if brand:
|
||||||
|
matched_records = [r for r in matched_records if self._brand_match(brand_filter, r)]
|
||||||
|
if not matched_records and brand_filter.get("manufacturer_brand"):
|
||||||
|
fallback_filter = {
|
||||||
|
"parent_brand": brand_filter.get("parent_brand"),
|
||||||
|
"manufacturer_brand": brand_filter.get("manufacturer_brand"),
|
||||||
|
"market_brand": None,
|
||||||
|
}
|
||||||
|
matched_records = [r for r in [self.records_by_id[rid] for rid in candidate_ids] if self._brand_match(fallback_filter, r)]
|
||||||
|
|
||||||
|
matched_records.sort(
|
||||||
|
key=lambda r: (
|
||||||
|
0 if is_preferred_cn_source_file(r.source_file) else 1,
|
||||||
|
r.device_name,
|
||||||
|
r.source_file,
|
||||||
|
r.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
matched_records = first_preferred_match(matched_records)
|
||||||
|
|
||||||
|
if matched_records:
|
||||||
|
best = matched_records[0]
|
||||||
|
return {
|
||||||
|
"matched": True,
|
||||||
|
"query_name": name,
|
||||||
|
"query_brand": brand,
|
||||||
|
"query_brand_parsed": brand_filter,
|
||||||
|
"best": asdict(best),
|
||||||
|
"candidates": [asdict(r) for r in matched_records[:limit]],
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions: List[str] = []
|
||||||
|
for alias in self.alias_index:
|
||||||
|
if query in alias or alias in query:
|
||||||
|
suggestions.append(alias)
|
||||||
|
if len(suggestions) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"matched": False,
|
||||||
|
"query_name": name,
|
||||||
|
"query_brand": brand,
|
||||||
|
"query_brand_parsed": brand_filter,
|
||||||
|
"reason": "No exact alias match.",
|
||||||
|
"candidates": [],
|
||||||
|
"suggestions": suggestions,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_records(repo_root: Path) -> List[DeviceRecord]:
|
||||||
|
brands_dir = repo_root / "brands"
|
||||||
|
records: List[DeviceRecord] = []
|
||||||
|
manual_catalog = load_manual_catalog(repo_root)
|
||||||
|
apply_manual_brand_overrides(manual_catalog)
|
||||||
|
|
||||||
|
for md_path in sorted(brands_dir.glob("*.md")):
|
||||||
|
records.extend(parse_brand_file(md_path))
|
||||||
|
|
||||||
|
records.extend(parse_manual_catalog(repo_root, manual_catalog))
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def export_index(records: List[DeviceRecord], output_path: Path) -> None:
|
||||||
|
lookup: Dict[str, List[str]] = {}
|
||||||
|
manufacturer_brands_in_data = sorted({record.manufacturer_brand for record in records})
|
||||||
|
parent_brands_in_data = sorted({record.parent_brand for record in records})
|
||||||
|
market_brands_in_data = sorted({record.market_brand for record in records})
|
||||||
|
all_brands_in_data = sorted(
|
||||||
|
set(manufacturer_brands_in_data)
|
||||||
|
| set(market_brands_in_data)
|
||||||
|
| set(MARKET_BRAND_TO_MANUFACTURER.keys())
|
||||||
|
)
|
||||||
|
manufacturer_stats = dict(sorted(Counter(record.manufacturer_brand for record in records).items()))
|
||||||
|
parent_stats = dict(sorted(Counter(record.parent_brand for record in records).items()))
|
||||||
|
market_brand_stats = dict(sorted(Counter(record.market_brand for record in records).items()))
|
||||||
|
|
||||||
|
brand_to_manufacturer = {}
|
||||||
|
for brand in all_brands_in_data:
|
||||||
|
if brand in MARKET_BRAND_TO_MANUFACTURER:
|
||||||
|
brand_to_manufacturer[brand] = MARKET_BRAND_TO_MANUFACTURER[brand]
|
||||||
|
else:
|
||||||
|
brand_to_manufacturer[brand] = resolve_parent_brand(brand)
|
||||||
|
|
||||||
|
parent_to_children: Dict[str, List[str]] = {}
|
||||||
|
for child, parent in brand_to_manufacturer.items():
|
||||||
|
parent_to_children.setdefault(parent, []).append(child)
|
||||||
|
for parent in parent_to_children:
|
||||||
|
parent_to_children[parent] = sorted(parent_to_children[parent])
|
||||||
|
|
||||||
|
all_aliases = {brand: brand_aliases(brand) for brand in all_brands_in_data}
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
for alias in record.aliases:
|
||||||
|
key = normalize_text(alias)
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
lookup.setdefault(key, []).append(record.id)
|
||||||
|
|
||||||
|
for key, ids in lookup.items():
|
||||||
|
lookup[key] = sorted(set(ids))
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"generated_on": date.today().isoformat(),
|
||||||
|
"total_records": len(records),
|
||||||
|
"brands": manufacturer_brands_in_data,
|
||||||
|
"brand_aliases": all_aliases,
|
||||||
|
"brand_management": {
|
||||||
|
"brands": all_brands_in_data,
|
||||||
|
"manufacturers": sorted(set(brand_to_manufacturer.values())),
|
||||||
|
"manufacturer_aliases": all_aliases,
|
||||||
|
"manufacturer_to_parent": brand_to_manufacturer,
|
||||||
|
"brand_to_manufacturer": brand_to_manufacturer,
|
||||||
|
"parent_to_children": parent_to_children,
|
||||||
|
"parent_aliases": {brand: brand_aliases(brand) for brand in parent_brands_in_data},
|
||||||
|
"market_brand_aliases": MARKET_BRAND_ALIASES,
|
||||||
|
"market_brand_to_manufacturer": MARKET_BRAND_TO_MANUFACTURER,
|
||||||
|
"market_brands": market_brands_in_data,
|
||||||
|
"parent_brands": parent_brands_in_data,
|
||||||
|
"stats": {
|
||||||
|
"manufacturer_brand": manufacturer_stats,
|
||||||
|
"parent_brand": parent_stats,
|
||||||
|
"market_brand": market_brand_stats,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"lookup": lookup,
|
||||||
|
"records": [asdict(r) for r in records],
|
||||||
|
}
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path.write_text(json.dumps(output, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="MobileModels device mapper")
|
||||||
|
parser.add_argument(
|
||||||
|
"--repo-root",
|
||||||
|
type=Path,
|
||||||
|
default=WORKSPACE_ROOT,
|
||||||
|
help="Path to workspace root",
|
||||||
|
)
|
||||||
|
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
build_cmd = subparsers.add_parser("build", help="Build JSON index")
|
||||||
|
build_cmd.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
default=Path("dist/device_index.json"),
|
||||||
|
help="Output JSON path",
|
||||||
|
)
|
||||||
|
|
||||||
|
find_cmd = subparsers.add_parser("find", help="Find a device by name + optional brand")
|
||||||
|
find_cmd.add_argument("--name", required=True, help="Raw device name from app")
|
||||||
|
find_cmd.add_argument("--brand", default=None, help="Optional raw brand from app")
|
||||||
|
find_cmd.add_argument("--limit", type=int, default=5, help="Max matched candidates")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
records = build_records(args.repo_root)
|
||||||
|
mapper = DeviceMapper(records)
|
||||||
|
|
||||||
|
if args.command == "build":
|
||||||
|
output_path: Path = args.output
|
||||||
|
if not output_path.is_absolute():
|
||||||
|
output_path = PROJECT_ROOT / output_path
|
||||||
|
export_index(records, output_path)
|
||||||
|
print(f"Built index: {output_path}")
|
||||||
|
print(f"Total records: {len(records)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == "find":
|
||||||
|
result = mapper.find(name=args.name, brand=args.brand, limit=args.limit)
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Export MobileModels records into MySQL-friendly seed SQL."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from device_mapper import (
|
||||||
|
MANUAL_SOURCE_FILE,
|
||||||
|
MARKET_BRAND_ALIASES,
|
||||||
|
MARKET_BRAND_TO_MANUFACTURER,
|
||||||
|
build_records,
|
||||||
|
brand_aliases,
|
||||||
|
normalize_text,
|
||||||
|
resolve_parent_brand,
|
||||||
|
)
|
||||||
|
from project_layout import PROJECT_ROOT, WORKSPACE_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
LEGACY_CODE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9,._/+\\-]{1,63}$")
|
||||||
|
|
||||||
|
|
||||||
|
def is_cn_source_file(source_file: str) -> bool:
|
||||||
|
return source_file.endswith("_cn.md")
|
||||||
|
|
||||||
|
|
||||||
|
def build_source_order(records: list[object]) -> list[str]:
|
||||||
|
source_files = sorted({record.source_file for record in records})
|
||||||
|
manual = [source for source in source_files if source == MANUAL_SOURCE_FILE]
|
||||||
|
source_files = [source for source in source_files if source != MANUAL_SOURCE_FILE]
|
||||||
|
cn = [source for source in source_files if is_cn_source_file(source)]
|
||||||
|
other = [source for source in source_files if not is_cn_source_file(source)]
|
||||||
|
return manual + sorted(cn) + sorted(other)
|
||||||
|
|
||||||
|
|
||||||
|
def build_source_weights(records: list[object]) -> tuple[dict[str, int], dict[str, float]]:
|
||||||
|
order = build_source_order(records)
|
||||||
|
total = len(order)
|
||||||
|
rank_map: dict[str, int] = {}
|
||||||
|
weight_map: dict[str, float] = {}
|
||||||
|
|
||||||
|
for idx, source_file in enumerate(order):
|
||||||
|
rank = idx + 1
|
||||||
|
weight = (((total - idx) / total) * 6) if total > 1 else 6
|
||||||
|
rank_map[source_file] = rank
|
||||||
|
weight_map[source_file] = round(weight, 3)
|
||||||
|
|
||||||
|
return rank_map, weight_map
|
||||||
|
|
||||||
|
|
||||||
|
def sql_quote(value: object | None) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "NULL"
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "1" if value else "0"
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return str(value)
|
||||||
|
text = str(value)
|
||||||
|
text = text.replace("\\", "\\\\").replace("'", "\\'")
|
||||||
|
return f"'{text}'"
|
||||||
|
|
||||||
|
|
||||||
|
def batched(items: list[tuple[str, ...]], batch_size: int) -> Iterable[list[tuple[str, ...]]]:
|
||||||
|
for start in range(0, len(items), batch_size):
|
||||||
|
yield items[start:start + batch_size]
|
||||||
|
|
||||||
|
|
||||||
|
def build_catalog_rows(records: list[object]) -> list[tuple[str, ...]]:
|
||||||
|
rank_map, weight_map = build_source_weights(records)
|
||||||
|
rows = []
|
||||||
|
seen_keys: set[tuple[str, str]] = set()
|
||||||
|
for record in records:
|
||||||
|
aliases = sorted({alias.strip() for alias in record.aliases if alias.strip()})
|
||||||
|
code_aliases = [alias for alias in aliases if is_legacy_code_alias(alias)]
|
||||||
|
primary_code = code_aliases[0] if code_aliases else None
|
||||||
|
other_codes = [alias for alias in code_aliases if alias != primary_code]
|
||||||
|
code_alias = " | ".join(other_codes) if other_codes else None
|
||||||
|
version_names = [alias for alias in aliases if not is_legacy_code_alias(alias)]
|
||||||
|
ver_name = " | ".join(version_names) if version_names else None
|
||||||
|
|
||||||
|
for alias in aliases:
|
||||||
|
alias_norm = normalize_text(alias)
|
||||||
|
if not alias_norm:
|
||||||
|
continue
|
||||||
|
dedupe_key = (record.id, alias_norm)
|
||||||
|
if dedupe_key in seen_keys:
|
||||||
|
continue
|
||||||
|
seen_keys.add(dedupe_key)
|
||||||
|
rows.append((
|
||||||
|
sql_quote(record.id),
|
||||||
|
sql_quote(alias),
|
||||||
|
sql_quote(alias_norm),
|
||||||
|
sql_quote(record.device_name),
|
||||||
|
sql_quote(record.brand),
|
||||||
|
sql_quote(record.manufacturer_brand),
|
||||||
|
sql_quote(record.parent_brand),
|
||||||
|
sql_quote(record.market_brand),
|
||||||
|
sql_quote(record.device_type),
|
||||||
|
sql_quote(primary_code),
|
||||||
|
sql_quote(code_alias),
|
||||||
|
sql_quote(ver_name),
|
||||||
|
sql_quote(record.source_file),
|
||||||
|
sql_quote(record.section),
|
||||||
|
sql_quote(rank_map[record.source_file]),
|
||||||
|
sql_quote(f"{weight_map[record.source_file]:.3f}"),
|
||||||
|
))
|
||||||
|
|
||||||
|
rows.sort(key=lambda item: (item[2], item[14], item[0], item[1]))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def build_brand_rows(records: list[object]) -> list[tuple[str, ...]]:
|
||||||
|
manufacturer_brands = sorted({record.manufacturer_brand for record in records})
|
||||||
|
parent_brands = sorted({record.parent_brand for record in records})
|
||||||
|
rows: dict[tuple[str, str], tuple[str, ...]] = {}
|
||||||
|
|
||||||
|
for brand in manufacturer_brands:
|
||||||
|
parent_brand = resolve_parent_brand(brand)
|
||||||
|
for alias in brand_aliases(brand):
|
||||||
|
alias_norm = normalize_text(alias)
|
||||||
|
if not alias_norm:
|
||||||
|
continue
|
||||||
|
rows[(alias_norm, "manufacturer")] = (
|
||||||
|
sql_quote(alias_norm),
|
||||||
|
sql_quote("manufacturer"),
|
||||||
|
sql_quote(brand),
|
||||||
|
sql_quote(brand),
|
||||||
|
sql_quote(parent_brand),
|
||||||
|
sql_quote(None),
|
||||||
|
)
|
||||||
|
|
||||||
|
for brand in parent_brands:
|
||||||
|
for alias in brand_aliases(brand):
|
||||||
|
alias_norm = normalize_text(alias)
|
||||||
|
if not alias_norm:
|
||||||
|
continue
|
||||||
|
rows[(alias_norm, "parent")] = (
|
||||||
|
sql_quote(alias_norm),
|
||||||
|
sql_quote("parent"),
|
||||||
|
sql_quote(brand),
|
||||||
|
sql_quote(None),
|
||||||
|
sql_quote(brand),
|
||||||
|
sql_quote(None),
|
||||||
|
)
|
||||||
|
|
||||||
|
for market_brand, aliases in MARKET_BRAND_ALIASES.items():
|
||||||
|
manufacturer_brand = MARKET_BRAND_TO_MANUFACTURER.get(market_brand, market_brand)
|
||||||
|
parent_brand = resolve_parent_brand(manufacturer_brand)
|
||||||
|
for alias in sorted(set([market_brand, *aliases])):
|
||||||
|
alias_norm = normalize_text(alias)
|
||||||
|
if not alias_norm:
|
||||||
|
continue
|
||||||
|
rows[(alias_norm, "market")] = (
|
||||||
|
sql_quote(alias_norm),
|
||||||
|
sql_quote("market"),
|
||||||
|
sql_quote(market_brand),
|
||||||
|
sql_quote(manufacturer_brand),
|
||||||
|
sql_quote(parent_brand),
|
||||||
|
sql_quote(market_brand),
|
||||||
|
)
|
||||||
|
|
||||||
|
return [rows[key] for key in sorted(rows)]
|
||||||
|
|
||||||
|
|
||||||
|
def is_legacy_code_alias(text: str) -> bool:
|
||||||
|
value = (text or "").strip()
|
||||||
|
if not value or not LEGACY_CODE_RE.match(value):
|
||||||
|
return False
|
||||||
|
return any(ch.isdigit() for ch in value)
|
||||||
|
|
||||||
|
|
||||||
|
def append_insert_block(lines: list[str], table_name: str, columns: list[str], rows: list[tuple[str, ...]], batch_size: int = 500) -> None:
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
column_sql = ", ".join(f"`{column}`" for column in columns)
|
||||||
|
for chunk in batched(rows, batch_size):
|
||||||
|
values_sql = ",\n".join(f" ({', '.join(row)})" for row in chunk)
|
||||||
|
lines.append(f"INSERT INTO `{table_name}` ({column_sql}) VALUES\n{values_sql};")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Export MobileModels MySQL seed SQL.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--repo-root",
|
||||||
|
type=Path,
|
||||||
|
default=WORKSPACE_ROOT,
|
||||||
|
help="Path to workspace root",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
default=Path("dist/mobilemodels_mysql_seed.sql"),
|
||||||
|
help="Output SQL path",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
repo_root = args.repo_root.resolve()
|
||||||
|
output_path = args.output if args.output.is_absolute() else PROJECT_ROOT / args.output
|
||||||
|
|
||||||
|
records = build_records(repo_root)
|
||||||
|
device_record_count = len(records)
|
||||||
|
catalog_rows = build_catalog_rows(records)
|
||||||
|
brand_rows = build_brand_rows(records)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"-- MobileModels MySQL seed",
|
||||||
|
"-- Generated by tools/export_mysql_seed.py",
|
||||||
|
"USE `mobilemodels`;",
|
||||||
|
"",
|
||||||
|
"START TRANSACTION;",
|
||||||
|
"",
|
||||||
|
"DELETE FROM `mm_device_catalog`;",
|
||||||
|
"DELETE FROM `mm_brand_lookup`;",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
append_insert_block(
|
||||||
|
lines,
|
||||||
|
"mm_device_catalog",
|
||||||
|
[
|
||||||
|
"record_id",
|
||||||
|
"model",
|
||||||
|
"alias_norm",
|
||||||
|
"device_name",
|
||||||
|
"brand",
|
||||||
|
"manufacturer_brand",
|
||||||
|
"parent_brand",
|
||||||
|
"market_brand",
|
||||||
|
"device_type",
|
||||||
|
"code",
|
||||||
|
"code_alias",
|
||||||
|
"ver_name",
|
||||||
|
"source_file",
|
||||||
|
"section",
|
||||||
|
"source_rank",
|
||||||
|
"source_weight",
|
||||||
|
],
|
||||||
|
catalog_rows,
|
||||||
|
)
|
||||||
|
append_insert_block(
|
||||||
|
lines,
|
||||||
|
"mm_brand_lookup",
|
||||||
|
[
|
||||||
|
"alias_norm",
|
||||||
|
"alias_type",
|
||||||
|
"canonical_brand",
|
||||||
|
"manufacturer_brand",
|
||||||
|
"parent_brand",
|
||||||
|
"market_brand",
|
||||||
|
],
|
||||||
|
brand_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.extend([
|
||||||
|
"COMMIT;",
|
||||||
|
"",
|
||||||
|
f"-- device_records: {device_record_count}",
|
||||||
|
f"-- device_catalog_rows: {len(catalog_rows)}",
|
||||||
|
f"-- brand_lookup_rows: {len(brand_rows)}",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path.write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
print(f"Exported MySQL seed: {output_path}")
|
||||||
|
print(f"device_records={device_record_count}")
|
||||||
|
print(f"device_catalog_rows={len(catalog_rows)}")
|
||||||
|
print(f"brand_lookup_rows={len(brand_rows)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
APP_ROOT="${APP_ROOT:-/app}"
|
||||||
|
DATA_ROOT="${MOBILEMODELS_DATA_ROOT:-/data}"
|
||||||
|
|
||||||
|
mkdir -p "$DATA_ROOT" "$DATA_ROOT/state"
|
||||||
|
|
||||||
|
sync_missing_dir_entries() {
|
||||||
|
src_dir="$1"
|
||||||
|
dst_dir="$2"
|
||||||
|
|
||||||
|
mkdir -p "$dst_dir"
|
||||||
|
|
||||||
|
for src_entry in "$src_dir"/*; do
|
||||||
|
[ -e "$src_entry" ] || continue
|
||||||
|
name="$(basename "$src_entry")"
|
||||||
|
dst_entry="$dst_dir/$name"
|
||||||
|
|
||||||
|
if [ -d "$src_entry" ]; then
|
||||||
|
sync_missing_dir_entries "$src_entry" "$dst_entry"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -e "$dst_entry" ] && [ ! -L "$dst_entry" ]; then
|
||||||
|
mkdir -p "$(dirname "$dst_entry")"
|
||||||
|
cp -a "$src_entry" "$dst_entry"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh_workspace_builtin_entries() {
|
||||||
|
src_dir="$1"
|
||||||
|
dst_dir="$2"
|
||||||
|
|
||||||
|
mkdir -p "$dst_dir"
|
||||||
|
|
||||||
|
for rel_path in \
|
||||||
|
brands \
|
||||||
|
misc \
|
||||||
|
CHANGELOG.md \
|
||||||
|
CHANGELOG_en.md \
|
||||||
|
LICENSE.txt
|
||||||
|
do
|
||||||
|
src_entry="$src_dir/$rel_path"
|
||||||
|
dst_entry="$dst_dir/$rel_path"
|
||||||
|
|
||||||
|
[ -e "$src_entry" ] || continue
|
||||||
|
|
||||||
|
rm -rf "$dst_entry"
|
||||||
|
mkdir -p "$(dirname "$dst_entry")"
|
||||||
|
cp -a "$src_entry" "$dst_entry"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -d "$src_dir/local" ]; then
|
||||||
|
sync_missing_dir_entries "$src_dir/local" "$dst_dir/local"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace_has_sync_metadata() {
|
||||||
|
[ -f "$DATA_ROOT/state/sync_status.json" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate_legacy_manual_catalog() {
|
||||||
|
legacy_dir="$DATA_ROOT/workspace/brands/local"
|
||||||
|
target_dir="$DATA_ROOT/workspace/local"
|
||||||
|
legacy_file="$legacy_dir/manual_catalog.json"
|
||||||
|
target_file="$target_dir/manual_catalog.json"
|
||||||
|
|
||||||
|
[ -d "$legacy_dir" ] || return
|
||||||
|
|
||||||
|
mkdir -p "$target_dir"
|
||||||
|
if [ -f "$legacy_file" ] && [ ! -f "$target_file" ]; then
|
||||||
|
cp -a "$legacy_file" "$target_file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Older builds accidentally copied local overlay files under brands/local.
|
||||||
|
# Once the real target exists, drop the misplaced directory to avoid confusion.
|
||||||
|
rm -rf "$legacy_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
init_path() {
|
||||||
|
rel_path="$1"
|
||||||
|
src_path="$APP_ROOT/$rel_path"
|
||||||
|
dst_path="$DATA_ROOT/$rel_path"
|
||||||
|
|
||||||
|
# Bind-mounted paths like /app/dist cannot be removed and replaced with symlinks.
|
||||||
|
# Keep the mount in place and only ensure the runtime copy exists.
|
||||||
|
if [ "$rel_path" = "dist" ]; then
|
||||||
|
if [ -d "$src_path" ]; then
|
||||||
|
if [ ! -e "$dst_path" ] && [ ! -L "$dst_path" ]; then
|
||||||
|
mkdir -p "$(dirname "$dst_path")"
|
||||||
|
cp -a "$src_path" "$dst_path"
|
||||||
|
else
|
||||||
|
sync_missing_dir_entries "$src_path" "$dst_path"
|
||||||
|
fi
|
||||||
|
elif [ ! -e "$dst_path" ] && [ ! -L "$dst_path" ]; then
|
||||||
|
mkdir -p "$(dirname "$dst_path")"
|
||||||
|
cp -a "$src_path" "$dst_path"
|
||||||
|
fi
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$rel_path" = "workspace" ] && [ -d "$src_path" ] && [ ! -L "$src_path" ]; then
|
||||||
|
if [ ! -e "$dst_path" ] && [ ! -L "$dst_path" ]; then
|
||||||
|
mkdir -p "$(dirname "$dst_path")"
|
||||||
|
cp -a "$src_path" "$dst_path"
|
||||||
|
elif workspace_has_sync_metadata; then
|
||||||
|
sync_missing_dir_entries "$src_path/local" "$dst_path/local"
|
||||||
|
else
|
||||||
|
refresh_workspace_builtin_entries "$src_path" "$dst_path"
|
||||||
|
fi
|
||||||
|
elif [ -d "$src_path" ]; then
|
||||||
|
if [ ! -e "$dst_path" ] && [ ! -L "$dst_path" ]; then
|
||||||
|
mkdir -p "$(dirname "$dst_path")"
|
||||||
|
cp -a "$src_path" "$dst_path"
|
||||||
|
else
|
||||||
|
sync_missing_dir_entries "$src_path" "$dst_path"
|
||||||
|
fi
|
||||||
|
elif [ ! -e "$dst_path" ] && [ ! -L "$dst_path" ]; then
|
||||||
|
mkdir -p "$(dirname "$dst_path")"
|
||||||
|
cp -a "$src_path" "$dst_path"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -L "$src_path" ]; then
|
||||||
|
current_target="$(readlink "$src_path" || true)"
|
||||||
|
if [ "$current_target" = "$dst_path" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
rm -f "$src_path"
|
||||||
|
else
|
||||||
|
rm -rf "$src_path"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ln -s "$dst_path" "$src_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
for rel_path in \
|
||||||
|
workspace \
|
||||||
|
dist
|
||||||
|
do
|
||||||
|
init_path "$rel_path"
|
||||||
|
done
|
||||||
|
|
||||||
|
migrate_legacy_manual_catalog
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Load MobileModels schema and seed data into MySQL."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from project_layout import PROJECT_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def mysql_env(password: str) -> dict[str, str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["MYSQL_PWD"] = password
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def mysql_command(user: str, host: str, port: int, database: str | None = None) -> list[str]:
|
||||||
|
command = [
|
||||||
|
"mysql",
|
||||||
|
f"--host={host}",
|
||||||
|
f"--port={port}",
|
||||||
|
f"--user={user}",
|
||||||
|
"--protocol=TCP",
|
||||||
|
"--default-character-set=utf8mb4",
|
||||||
|
]
|
||||||
|
if database:
|
||||||
|
command.append(database)
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def mysqladmin_ping(user: str, password: str, host: str, port: int) -> bool:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
"mysqladmin",
|
||||||
|
f"--host={host}",
|
||||||
|
f"--port={port}",
|
||||||
|
f"--user={user}",
|
||||||
|
"--protocol=TCP",
|
||||||
|
"ping",
|
||||||
|
"--silent",
|
||||||
|
],
|
||||||
|
env=mysql_env(password),
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
return proc.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_mysql(user: str, password: str, host: str, port: int, timeout: int) -> None:
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
if mysqladmin_ping(user, password, host, port):
|
||||||
|
return
|
||||||
|
time.sleep(2)
|
||||||
|
raise RuntimeError(f"MySQL 未在 {timeout}s 内就绪: {host}:{port}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_sql_file(user: str, password: str, host: str, port: int, path: Path, database: str | None = None) -> None:
|
||||||
|
sql_text = path.read_text(encoding="utf-8")
|
||||||
|
proc = subprocess.run(
|
||||||
|
mysql_command(user, host, port, database=database),
|
||||||
|
env=mysql_env(password),
|
||||||
|
input=sql_text,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
message = proc.stderr.strip() or proc.stdout.strip() or f"mysql exited with {proc.returncode}"
|
||||||
|
raise RuntimeError(f"执行 SQL 文件失败 {path}: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
def sql_string(value: str) -> str:
|
||||||
|
return value.replace("\\", "\\\\").replace("'", "''")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_reader_user(
|
||||||
|
user: str,
|
||||||
|
password: str,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
database: str,
|
||||||
|
reader_user: str,
|
||||||
|
reader_password: str,
|
||||||
|
) -> None:
|
||||||
|
sql = f"""
|
||||||
|
CREATE USER IF NOT EXISTS '{sql_string(reader_user)}'@'%' IDENTIFIED BY '{sql_string(reader_password)}';
|
||||||
|
ALTER USER '{sql_string(reader_user)}'@'%' IDENTIFIED BY '{sql_string(reader_password)}';
|
||||||
|
GRANT SELECT ON `{database}`.* TO '{sql_string(reader_user)}'@'%';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
"""
|
||||||
|
proc = subprocess.run(
|
||||||
|
mysql_command(user, host, port),
|
||||||
|
env=mysql_env(password),
|
||||||
|
input=sql,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
message = proc.stderr.strip() or proc.stdout.strip() or f"mysql exited with {proc.returncode}"
|
||||||
|
raise RuntimeError(f"创建只读账号失败: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Load MobileModels schema and seed data into MySQL.")
|
||||||
|
parser.add_argument("--schema", type=Path, default=Path("sql/mobilemodels_mysql_schema.sql"))
|
||||||
|
parser.add_argument("--seed", type=Path, default=Path("dist/mobilemodels_mysql_seed.sql"))
|
||||||
|
parser.add_argument("--host", default=os.environ.get("MYSQL_HOST", "mysql"))
|
||||||
|
parser.add_argument("--port", type=int, default=int(os.environ.get("MYSQL_PORT", "3306")))
|
||||||
|
parser.add_argument("--user", default=os.environ.get("MYSQL_ROOT_USER", "root"))
|
||||||
|
parser.add_argument("--password", default=os.environ.get("MYSQL_ROOT_PASSWORD", "mobilemodels_root"))
|
||||||
|
parser.add_argument("--database", default=os.environ.get("MYSQL_DATABASE", "mobilemodels"))
|
||||||
|
parser.add_argument("--reader-user", default=os.environ.get("MYSQL_READER_USER", ""))
|
||||||
|
parser.add_argument("--reader-password", default=os.environ.get("MYSQL_READER_PASSWORD", ""))
|
||||||
|
parser.add_argument("--wait-timeout", type=int, default=120)
|
||||||
|
parser.add_argument("--check-only", action="store_true", help="Only check MySQL readiness")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
schema_path = args.schema if args.schema.is_absolute() else PROJECT_ROOT / args.schema
|
||||||
|
seed_path = args.seed if args.seed.is_absolute() else PROJECT_ROOT / args.seed
|
||||||
|
|
||||||
|
wait_for_mysql(args.user, args.password, args.host, args.port, args.wait_timeout)
|
||||||
|
|
||||||
|
if args.check_only:
|
||||||
|
print(f"MySQL ready: {args.host}:{args.port}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
run_sql_file(args.user, args.password, args.host, args.port, schema_path)
|
||||||
|
run_sql_file(args.user, args.password, args.host, args.port, seed_path)
|
||||||
|
|
||||||
|
if args.reader_user and args.reader_password:
|
||||||
|
ensure_reader_user(
|
||||||
|
args.user,
|
||||||
|
args.password,
|
||||||
|
args.host,
|
||||||
|
args.port,
|
||||||
|
args.database,
|
||||||
|
args.reader_user,
|
||||||
|
args.reader_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Loaded schema: {schema_path}")
|
||||||
|
print(f"Loaded seed: {seed_path}")
|
||||||
|
if args.reader_user:
|
||||||
|
print(f"Ensured reader user: {args.reader_user}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Shared path helpers for the project layout."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
WORKSPACE_ROOT = PROJECT_ROOT / "workspace"
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Sync selected upstream MobileModels data into this repository."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import filecmp
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from project_layout import PROJECT_ROOT, WORKSPACE_ROOT
|
||||||
|
|
||||||
|
DEFAULT_REPO_URL = "https://github.com/KHwang9883/MobileModels.git"
|
||||||
|
DEFAULT_BRANCH = "master"
|
||||||
|
SYNC_PATHS = [
|
||||||
|
"brands",
|
||||||
|
"misc",
|
||||||
|
"CHANGELOG.md",
|
||||||
|
"CHANGELOG_en.md",
|
||||||
|
"LICENSE.txt",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list[str], cwd: Path | None = None) -> None:
|
||||||
|
subprocess.run(cmd, cwd=cwd or PROJECT_ROOT, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_path(path: Path) -> None:
|
||||||
|
if path.is_dir():
|
||||||
|
shutil.rmtree(path)
|
||||||
|
elif path.exists():
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def sync_path(src: Path, dst: Path) -> None:
|
||||||
|
if src.is_dir():
|
||||||
|
dst.mkdir(parents=True, exist_ok=True)
|
||||||
|
source_children = {child.name for child in src.iterdir()}
|
||||||
|
|
||||||
|
for existing in dst.iterdir():
|
||||||
|
if existing.name not in source_children:
|
||||||
|
remove_path(existing)
|
||||||
|
|
||||||
|
for child in src.iterdir():
|
||||||
|
sync_path(child, dst / child.name)
|
||||||
|
return
|
||||||
|
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if dst.exists() and filecmp.cmp(src, dst, shallow=False):
|
||||||
|
return
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_selected_paths(upstream_root: Path) -> None:
|
||||||
|
for relative_path in SYNC_PATHS:
|
||||||
|
src = upstream_root / relative_path
|
||||||
|
dst = WORKSPACE_ROOT / relative_path
|
||||||
|
if not src.exists():
|
||||||
|
raise FileNotFoundError(f"Missing upstream path: {relative_path}")
|
||||||
|
sync_path(src, dst)
|
||||||
|
|
||||||
|
|
||||||
|
def build_index(output_path: str) -> None:
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(PROJECT_ROOT / "tools/device_mapper.py"),
|
||||||
|
"--repo-root",
|
||||||
|
str(WORKSPACE_ROOT),
|
||||||
|
"build",
|
||||||
|
"--output",
|
||||||
|
output_path,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def export_mysql_seed(output_path: str) -> None:
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(PROJECT_ROOT / "tools/export_mysql_seed.py"),
|
||||||
|
"--output",
|
||||||
|
output_path,
|
||||||
|
"--repo-root",
|
||||||
|
str(WORKSPACE_ROOT),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_mysql_seed(seed_path: str) -> None:
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(PROJECT_ROOT / "tools/load_mysql_seed.py"),
|
||||||
|
"--seed",
|
||||||
|
seed_path,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Sync upstream MobileModels raw data and optionally rebuild the device index."
|
||||||
|
)
|
||||||
|
parser.add_argument("--repo-url", default=DEFAULT_REPO_URL, help="Upstream git repository URL")
|
||||||
|
parser.add_argument("--branch", default=DEFAULT_BRANCH, help="Upstream branch to sync from")
|
||||||
|
parser.add_argument(
|
||||||
|
"--build-index",
|
||||||
|
action="store_true",
|
||||||
|
help="Rebuild dist/device_index.json after syncing upstream data",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--index-output",
|
||||||
|
default="dist/device_index.json",
|
||||||
|
help="Output path for the rebuilt device index",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--export-mysql-seed",
|
||||||
|
action="store_true",
|
||||||
|
help="Export MySQL seed SQL after syncing upstream data",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mysql-seed-output",
|
||||||
|
default="dist/mobilemodels_mysql_seed.sql",
|
||||||
|
help="Output path for the exported MySQL seed SQL",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--load-mysql",
|
||||||
|
action="store_true",
|
||||||
|
help="Load schema and seed data into MySQL after exporting seed SQL",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="mobilemodels-upstream-") as tmpdir:
|
||||||
|
upstream_root = Path(tmpdir) / "upstream"
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"clone",
|
||||||
|
"--depth",
|
||||||
|
"1",
|
||||||
|
"--branch",
|
||||||
|
args.branch,
|
||||||
|
args.repo_url,
|
||||||
|
str(upstream_root),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
sync_selected_paths(upstream_root)
|
||||||
|
|
||||||
|
if args.build_index:
|
||||||
|
build_index(args.index_output)
|
||||||
|
|
||||||
|
if args.export_mysql_seed or args.load_mysql:
|
||||||
|
export_mysql_seed(args.mysql_seed_output)
|
||||||
|
|
||||||
|
if args.load_mysql:
|
||||||
|
load_mysql_seed(args.mysql_seed_output)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+1485
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,482 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>MobileModels 使用文档</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f5f7fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--text: #1c2430;
|
||||||
|
--sub: #566173;
|
||||||
|
--line: #d9e0ea;
|
||||||
|
--brand: #0f6fff;
|
||||||
|
--nav-height: 52px;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding-top: var(--nav-height);
|
||||||
|
font-family: "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
|
||||||
|
background: radial-gradient(circle at 0 0, #eef4ff 0, var(--bg) 40%), var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.top-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: linear-gradient(180deg, #1f2a3a, #1a2431);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: 0 10px 30px rgba(14, 25, 42, 0.16);
|
||||||
|
}
|
||||||
|
.top-nav-inner {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
height: var(--nav-height);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.top-nav .brand,
|
||||||
|
.top-nav .item {
|
||||||
|
color: #d6e3f7;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.top-nav .brand {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-right: 8px;
|
||||||
|
color: #f4f8ff;
|
||||||
|
}
|
||||||
|
.top-nav .item.active {
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.wrap {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 24px auto;
|
||||||
|
padding: 0 16px 32px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.doc-nav-card {
|
||||||
|
position: sticky;
|
||||||
|
top: calc(var(--nav-height) + 16px);
|
||||||
|
z-index: 10;
|
||||||
|
max-height: calc(100vh - var(--nav-height) - 32px);
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
box-shadow: 0 6px 18px rgba(36, 56, 89, 0.06);
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.sub {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
color: var(--sub);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.doc-toc {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
.doc-toc-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 0 10px;
|
||||||
|
border-bottom: 1px solid #e7edf7;
|
||||||
|
}
|
||||||
|
.doc-toc-section:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
.doc-toc-title {
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #587095;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.doc-toc-links {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.doc-toc-link {
|
||||||
|
display: block;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #33527f;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.doc-toc-link:hover {
|
||||||
|
background: #f4f8ff;
|
||||||
|
border-color: #e0e8f6;
|
||||||
|
}
|
||||||
|
.doc-toc-link.level-2 {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #28466f;
|
||||||
|
}
|
||||||
|
.doc-toc-link.level-3 {
|
||||||
|
margin-left: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.doc-toc-link.level-4 {
|
||||||
|
margin-left: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.doc-shell {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.doc-shell .markdown-body {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.markdown-body {
|
||||||
|
color: #223047;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
.markdown-body > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.markdown-body > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.markdown-body h1,
|
||||||
|
.markdown-body h2,
|
||||||
|
.markdown-body h3,
|
||||||
|
.markdown-body h4 {
|
||||||
|
margin: 1.2em 0 0.55em;
|
||||||
|
color: #1b2a42;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.markdown-body h1 { font-size: 32px; }
|
||||||
|
.markdown-body h2 { font-size: 24px; }
|
||||||
|
.markdown-body h3 { font-size: 19px; }
|
||||||
|
.markdown-body h4 { font-size: 16px; }
|
||||||
|
.markdown-body p,
|
||||||
|
.markdown-body ul,
|
||||||
|
.markdown-body ol,
|
||||||
|
.markdown-body blockquote {
|
||||||
|
margin: 0 0 1em;
|
||||||
|
}
|
||||||
|
.markdown-body ul,
|
||||||
|
.markdown-body ol {
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
.markdown-body li + li {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.markdown-body blockquote {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-left: 4px solid #cddcf6;
|
||||||
|
border-radius: 0 10px 10px 0;
|
||||||
|
background: #f7faff;
|
||||||
|
color: #52627a;
|
||||||
|
}
|
||||||
|
.markdown-body pre {
|
||||||
|
margin: 0 0 1em;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
background: #f6f8fb;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.markdown-body pre code {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
.markdown-body code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.92em;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #edf3ff;
|
||||||
|
color: #294f88;
|
||||||
|
border: 1px solid #d7e3fb;
|
||||||
|
}
|
||||||
|
.markdown-body hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid #d8e1ef;
|
||||||
|
margin: 1.2em 0;
|
||||||
|
}
|
||||||
|
.markdown-body a {
|
||||||
|
color: #0f6fff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.markdown-body a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.markdown-body h2,
|
||||||
|
.markdown-body h3,
|
||||||
|
.markdown-body h4 {
|
||||||
|
scroll-margin-top: calc(var(--nav-height) + 20px);
|
||||||
|
}
|
||||||
|
@media (max-width: 1020px) {
|
||||||
|
.wrap {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.doc-nav-card {
|
||||||
|
position: static;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="top-nav">
|
||||||
|
<div class="top-nav-inner">
|
||||||
|
<a href="/web/device_query.html" class="brand">MobileModels</a>
|
||||||
|
<a href="/web/device_query.html" class="item">设备查询</a>
|
||||||
|
<a href="/web/brand_management.html" class="item">数据管理</a>
|
||||||
|
<a href="/web/device_query.html?view=docs" class="item active">使用文档</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
<section class="card doc-nav-card">
|
||||||
|
<h1 class="title" id="docTitle">使用文档</h1>
|
||||||
|
<div id="docToc" class="doc-toc" aria-label="文档目录"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card doc-shell">
|
||||||
|
<div id="docContent" class="markdown-body">加载中...</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const ALLOWED_DOCS = new Map([
|
||||||
|
["/README.md", "项目使用文档"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const path = params.get("path") || "/README.md";
|
||||||
|
const title = params.get("title") || ALLOWED_DOCS.get(path) || "使用文档";
|
||||||
|
const docTitleEl = document.getElementById("docTitle");
|
||||||
|
const docContentEl = document.getElementById("docContent");
|
||||||
|
const docTocEl = document.getElementById("docToc");
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return String(text || "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInlineMarkdown(text) {
|
||||||
|
let html = escapeHtml(text || "");
|
||||||
|
html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||||
|
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||||
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(text) {
|
||||||
|
return String(text || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/<[^>]+>/g, "")
|
||||||
|
.replace(/[^0-9a-z\u4e00-\u9fff]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "") || "section";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(text) {
|
||||||
|
const normalized = String(text || "").replace(/\r\n/g, "\n");
|
||||||
|
const lines = normalized.split("\n");
|
||||||
|
const chunks = [];
|
||||||
|
const headingIds = new Map();
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i];
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith("```")) {
|
||||||
|
const lang = trimmed.slice(3).trim();
|
||||||
|
const codeLines = [];
|
||||||
|
i += 1;
|
||||||
|
while (i < lines.length && !lines[i].trim().startsWith("```")) {
|
||||||
|
codeLines.push(lines[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if (i < lines.length) i += 1;
|
||||||
|
const className = lang ? ` class="language-${escapeHtml(lang)}"` : "";
|
||||||
|
chunks.push(`<pre><code${className}>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = trimmed.match(/^(#{1,4})\s+(.*)$/);
|
||||||
|
if (heading) {
|
||||||
|
const level = heading[1].length;
|
||||||
|
const renderedText = renderInlineMarkdown(heading[2]);
|
||||||
|
const baseId = slugify(heading[2]);
|
||||||
|
const count = headingIds.get(baseId) || 0;
|
||||||
|
headingIds.set(baseId, count + 1);
|
||||||
|
const headingId = count ? `${baseId}-${count + 1}` : baseId;
|
||||||
|
chunks.push(`<h${level} id="${headingId}">${renderedText}</h${level}>`);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^---+$/.test(trimmed) || /^___+$/.test(trimmed)) {
|
||||||
|
chunks.push("<hr />");
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^>\s?/.test(trimmed)) {
|
||||||
|
const quoteLines = [];
|
||||||
|
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
|
||||||
|
quoteLines.push(lines[i].trim().replace(/^>\s?/, ""));
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
chunks.push(`<blockquote>${renderInlineMarkdown(quoteLines.join(" "))}</blockquote>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[-*]\s+/.test(trimmed)) {
|
||||||
|
const items = [];
|
||||||
|
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
|
||||||
|
items.push(lines[i].trim().replace(/^[-*]\s+/, ""));
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
chunks.push(`<ul>${items.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("")}</ul>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d+\.\s+/.test(trimmed)) {
|
||||||
|
const items = [];
|
||||||
|
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
|
||||||
|
items.push(lines[i].trim().replace(/^\d+\.\s+/, ""));
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
chunks.push(`<ol>${items.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("")}</ol>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paragraphLines = [];
|
||||||
|
while (i < lines.length) {
|
||||||
|
const candidate = lines[i];
|
||||||
|
const candidateTrim = candidate.trim();
|
||||||
|
if (!candidateTrim) break;
|
||||||
|
if (
|
||||||
|
candidateTrim.startsWith("```")
|
||||||
|
|| /^(#{1,4})\s+/.test(candidateTrim)
|
||||||
|
|| /^[-*]\s+/.test(candidateTrim)
|
||||||
|
|| /^\d+\.\s+/.test(candidateTrim)
|
||||||
|
|| /^>\s?/.test(candidateTrim)
|
||||||
|
|| /^---+$/.test(candidateTrim)
|
||||||
|
|| /^___+$/.test(candidateTrim)
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
paragraphLines.push(candidateTrim);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
chunks.push(`<p>${renderInlineMarkdown(paragraphLines.join(" "))}</p>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToc(container) {
|
||||||
|
const headings = Array.from(container.querySelectorAll("h2, h3, h4"));
|
||||||
|
if (!headings.length) {
|
||||||
|
docTocEl.innerHTML = `<div class="sub">当前文档没有可用目录。</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const groups = [];
|
||||||
|
let currentGroup = null;
|
||||||
|
headings.forEach((heading) => {
|
||||||
|
const level = Number(heading.tagName.slice(1));
|
||||||
|
const item = {
|
||||||
|
id: heading.id,
|
||||||
|
text: escapeHtml(heading.textContent || ""),
|
||||||
|
level,
|
||||||
|
};
|
||||||
|
if (level === 2 || !currentGroup) {
|
||||||
|
currentGroup = { heading: item, children: [] };
|
||||||
|
groups.push(currentGroup);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentGroup.children.push(item);
|
||||||
|
});
|
||||||
|
docTocEl.innerHTML = groups.map((group) => `
|
||||||
|
<section class="doc-toc-section">
|
||||||
|
<div class="doc-toc-title">${group.heading.text}</div>
|
||||||
|
<div class="doc-toc-links">
|
||||||
|
<a class="doc-toc-link level-${group.heading.level}" href="#${group.heading.id}">${group.heading.text}</a>
|
||||||
|
${group.children.map((item) => `<a class="doc-toc-link level-${item.level}" href="#${item.id}">${item.text}</a>`).join("")}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_DOCS.has(path)) {
|
||||||
|
docTitleEl.textContent = "使用文档";
|
||||||
|
docContentEl.innerHTML = "<p>当前只允许查看预设文档。</p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.title = `${title} - MobileModels`;
|
||||||
|
docTitleEl.textContent = "使用文档";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(path, { cache: "no-store" });
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
const text = await resp.text();
|
||||||
|
docContentEl.innerHTML = renderMarkdown(text);
|
||||||
|
buildToc(docContentEl);
|
||||||
|
} catch (err) {
|
||||||
|
docContentEl.innerHTML = `<p>加载失败</p><pre>${escapeHtml(err.message || String(err))}</pre>`;
|
||||||
|
docTocEl.innerHTML = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
# 更新日志
|
# 更新日志
|
||||||
|
### 2026-03-17
|
||||||
|
- `oppo_cn` 新增 OPPO Find N6。
|
||||||
|
### 2026-03-16
|
||||||
|
- `xiaomi-wear` 新增 Xiaomi Watch S5。
|
||||||
### 2026-03-12
|
### 2026-03-12
|
||||||
- `xiaomi` 新增 POCO C85x 5G。
|
- `xiaomi` 新增 POCO C85x 5G。
|
||||||
### 2026-03-11
|
### 2026-03-11
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
### 2026-03-17
|
||||||
|
- `oppo_global_en` Add OPPO Find N6.
|
||||||
### 2026-03-12
|
### 2026-03-12
|
||||||
- `xiaomi_en` Add POCO C85x 5G.
|
- `xiaomi_en` Add POCO C85x 5G.
|
||||||
### 2026-03-08
|
### 2026-03-08
|
||||||
@@ -130,6 +130,12 @@
|
|||||||
|
|
||||||
`PKH120`: OPPO Find N5 卫星通信版
|
`PKH120`: OPPO Find N5 卫星通信版
|
||||||
|
|
||||||
|
**OPPO Find N6:**
|
||||||
|
|
||||||
|
`PLP110`: OPPO Find N6
|
||||||
|
|
||||||
|
`PLP120`: OPPO Find N6 卫星通信版
|
||||||
|
|
||||||
## Reno 系列
|
## Reno 系列
|
||||||
|
|
||||||
**OPPO Reno:**
|
**OPPO Reno:**
|
||||||
@@ -93,6 +93,10 @@
|
|||||||
|
|
||||||
`CPH2671`: OPPO Find N5
|
`CPH2671`: OPPO Find N5
|
||||||
|
|
||||||
|
**OPPO Find N6:**
|
||||||
|
|
||||||
|
`CPH2765`: OPPO Find N6
|
||||||
|
|
||||||
## Reno series
|
## Reno series
|
||||||
|
|
||||||
**OPPO Reno:**
|
**OPPO Reno:**
|
||||||
@@ -337,9 +341,9 @@
|
|||||||
|
|
||||||
`CPH2811`: OPPO Reno15 Pro 5G / OPPO Reno15 Pro Max 5G
|
`CPH2811`: OPPO Reno15 Pro 5G / OPPO Reno15 Pro Max 5G
|
||||||
|
|
||||||
**OPPO Reno15 F / OPPO Reno15 FS / OPPO Reno15 C / OPPO Reno15 A:**
|
**OPPO Reno15 F / OPPO Reno15 FS / OPPO Reno15c / OPPO Reno15 A:**
|
||||||
|
|
||||||
`CPH2801`: OPPO Reno15 F 5G / OPPO Reno15 FS 5G / OPPO Reno15 C 5G / OPPO Reno15 A
|
`CPH2801`: OPPO Reno15 F 5G / OPPO Reno15 FS 5G / OPPO Reno15c 5G / OPPO Reno15 A
|
||||||
|
|
||||||
## F series
|
## F series
|
||||||
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
> Providing your device's codename is welcome. You can find it in version of system firmware, starting with `PD`.
|
> Providing your device's codename is welcome. You can find it in version of system firmware, starting with `PD`.
|
||||||
>
|
>
|
||||||
> Please report any errors by [opening an issue](https://github.com/KHwang9883/MobileModels/issues).
|
> Please report any errors through your project delivery channel.
|
||||||
|
|
||||||
## vivo X series
|
## vivo X series
|
||||||
|
|
||||||
@@ -201,6 +201,12 @@
|
|||||||
|
|
||||||
`M2412W1`: 小米腕部血压记录仪 (Xiaomi Watch H1 E)
|
`M2412W1`: 小米腕部血压记录仪 (Xiaomi Watch H1 E)
|
||||||
|
|
||||||
|
**[`P62`] Xiaomi Watch S5:**
|
||||||
|
|
||||||
|
`M2530W1`: Xiaomi Watch S5 蓝牙版
|
||||||
|
|
||||||
|
`M2517W1`: Xiaomi Watch S5 eSIM 版
|
||||||
|
|
||||||
## 小米智能眼镜
|
## 小米智能眼镜
|
||||||
|
|
||||||
**[`O95`] Xiaomi AI Glasses:**
|
**[`O95`] Xiaomi AI Glasses:**
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"brands": [
|
||||||
|
{
|
||||||
|
"name": "学而思",
|
||||||
|
"parent_brand": "好未来",
|
||||||
|
"aliases": [
|
||||||
|
"学而思"
|
||||||
|
],
|
||||||
|
"updated_at": "2026-04-14T17:05:28+08:00",
|
||||||
|
"created_at": "2026-04-14T17:05:27+08:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"devices": []
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user