优化新增耳机流程,优化界面

This commit is contained in:
eafonyang
2026-07-20 15:45:52 +08:00
parent d292c23dc0
commit d2a8ef2f2d
14 changed files with 844 additions and 356 deletions
+19 -5
View File
@@ -13,7 +13,7 @@ const { authMiddleware } = require('../middleware/auth');
const { fetchAndValidateCurve } = require('../services/curveClient');
const { uploadMeasurementToS3, getMeasurementFromS3, moveMeasurementOnS3 } = require('../services/measurementStorage');
const { getEqCacheKeys, getEqCacheField } = require('../services/eqCacheStorage');
const { fetchFromSquigLink, downloadTxtFile } = require('../services/squiglink');
const { fetchFromSquigLink, downloadTxtFile, isDirectTxtUrl, fetchDirectTxtUrl } = require('../services/squiglink');
/**
* 将 TXT 频响文件内容转换为 CSV 格式
@@ -304,7 +304,7 @@ router.get('/api/models/:model_id', async (req, res) => {
}
});
// POST /api/models/squiglink-fetch — 从 squig.link share URL 抓取频响数据
// POST /api/models/squiglink-fetch — 从 squig.link share URL 或直接 TXT URL 抓取频响数据
router.post('/api/models/squiglink-fetch', async (req, res) => {
try {
const { share_url, selected_file } = req.body;
@@ -314,13 +314,27 @@ router.post('/api/models/squiglink-fetch', async (req, res) => {
logger.info(`SquigLink fetch: ${share_url}${selected_file ? ' (selected=' + selected_file + ')' : ''}`);
// 直接 TXT 文件 URL:无需 phone_book 匹配,直接下载解析
if (!selected_file && isDirectTxtUrl(share_url)) {
const result = await fetchDirectTxtUrl(share_url);
const csvContent = convertTxtToCsv(result.buffer);
return res.json(ApiResponse.success({
brand_name: result.brandName,
model_name: result.modelName,
form: result.form || null,
csv_content: csvContent,
data_url: result.url,
matches: [],
}));
}
// 如果用户从候选列表中选择了特定文件,直接下载
if (selected_file) {
const { parseShareUrl, detectFormFromUrl } = require('../services/squiglink');
const { baseUrl } = parseShareUrl(share_url);
const { baseUrl, pathPrefix } = parseShareUrl(share_url);
const [downloadResult, detectedForm] = await Promise.all([
downloadTxtFile(baseUrl, selected_file),
detectFormFromUrl(baseUrl),
downloadTxtFile(baseUrl, selected_file, pathPrefix),
detectFormFromUrl(baseUrl, pathPrefix),
]);
const csvContent = convertTxtToCsv(downloadResult.buffer);
// 从 file 名解析品牌和型号
+139 -40
View File
@@ -2,9 +2,10 @@
* squig.link 频响数据抓取服务
*
* 核心机制:
* - 每个 squig.link 实例都有 {baseURL}/data/phone_book.json 清单文件
* - share URL 的 share 参数(下划线→空格)匹配 phone_book.json 中的 file 字段
* - 数据 URL = {baseURL}/{DIR}/{file值} L.txtDIR 通常为 data/
* - 每个 squig.link 实例都有 {baseURL}{pathPrefix}/data/phone_book.json 清单文件
* - 多 db 实例(如 earphonesarchive)通过 URL 路径前缀(/headphones、/iems)区分数据目录
* - share URL 的 share 参数支持逗号分隔多项(目标曲线 + 耳机),下划线→空格后匹配 phone_book.json 中的 file 字段
* - 数据 URL = {baseURL}{pathPrefix}/data/{file值} L.txt
*/
const axios = require('axios');
const logger = require('../config/logger');
@@ -48,12 +49,13 @@ async function getSquigSites() {
}
/**
* 解析 share URL,提取 base URL 和 share 参数
* 解析 share URL,提取 base URL、路径前缀和 share 参数
* 支持格式:
* https://theaudiostore.squig.link/?share=Nostalgia_Audio_Camelot
* https://squig.link/?share=Moondrop_Chu_2
* https://earphonesarchive.squig.link/headphones/?share=5128_DF_Target,Asus_ROG_x_Hifiman_Kithara_(center)
* @param {string} shareUrl
* @returns {{ baseUrl: string, shareParam: string }}
* @returns {{ baseUrl: string, pathPrefix: string, shareParam: string, shareItems: string[] }}
*/
function parseShareUrl(shareUrl) {
const url = new URL(shareUrl.trim());
@@ -63,16 +65,22 @@ function parseShareUrl(shareUrl) {
}
// base URL = origin(如 https://theaudiostore.squig.link
const baseUrl = url.origin;
return { baseUrl, shareParam };
// 路径前缀(多 db 实例如 /headphones),用于定位 phone_book.json 和 data/ 目录
let pathPrefix = url.pathname.replace(/\/+$/, '');
if (pathPrefix === '/') pathPrefix = '';
// share 参数可能为逗号分隔的多项(目标曲线 + 耳机型号)
const shareItems = shareParam.split(',').map(s => s.trim()).filter(Boolean);
return { baseUrl, pathPrefix, shareParam, shareItems };
}
/**
* 从 share URL 自动检测佩戴方式
* 通过 squigsites.json 查找实例配置中的 dbs.type 并映射
* @param {string} baseUrl - 如 https://theaudiostore.squig.link
* @param {string} pathPrefix - URL 路径前缀,如 /headphones
* @returns {Promise<string|null>} 如 'in-ear' / 'over-ear' / 'earbud' / null
*/
async function detectFormFromUrl(baseUrl) {
async function detectFormFromUrl(baseUrl, pathPrefix = '') {
try {
// 从 URL 提取 username(子域名)
const urlObj = new URL(baseUrl);
@@ -89,15 +97,19 @@ async function detectFormFromUrl(baseUrl) {
const site = sites.find((s) => s.username === username);
if (!site || !Array.isArray(site.dbs) || site.dbs.length === 0) return null;
// 提取 URL 路径匹配 db folder
const urlPath = urlObj.pathname || '/';
// 使用实际 URL 路径匹配 db folder,优先最具体的匹配
const urlPath = pathPrefix ? `${pathPrefix}/` : '/';
let matchedType = null;
let bestLen = -1;
for (const db of site.dbs) {
const folder = db.folder || '/';
// 如果 URL 路径以 db.folder 开头,则匹配
if (urlPath.startsWith(folder) || folder === '/') {
matchedType = db.type;
if (folder.length > bestLen) {
bestLen = folder.length;
matchedType = db.type;
}
}
}
@@ -122,10 +134,11 @@ async function detectFormFromUrl(baseUrl) {
/**
* 获取 squig.link 实例的 phone_book.json
* @param {string} baseUrl - 如 https://theaudiostore.squig.link
* @param {string} pathPrefix - URL 路径前缀,如 /headphones
* @returns {Promise<Array>}
*/
async function fetchPhoneBook(baseUrl) {
const url = `${baseUrl}/data/phone_book.json`;
async function fetchPhoneBook(baseUrl, pathPrefix = '') {
const url = `${baseUrl}${pathPrefix}/data/phone_book.json`;
logger.info(`Fetching phone_book: ${url}`);
const res = await axios.get(url, {
timeout: FETCH_TIMEOUT,
@@ -211,9 +224,10 @@ function fuzzyFindInPhoneBook(phoneBook, shareParam) {
* 按优先级尝试多种后缀:L声道 → R声道 → 无后缀 → 带编号样本
* @param {string} baseUrl
* @param {string} fileName - phone_book 中的 file 值
* @param {string} pathPrefix - URL 路径前缀,如 /headphones
* @returns {Promise<{ buffer: Buffer, url: string }>}
*/
async function downloadTxtFile(baseUrl, fileName) {
async function downloadTxtFile(baseUrl, fileName, pathPrefix = '') {
// 按优先级尝试的后缀列表
const suffixes = [
' L.txt', ' R.txt', '.txt',
@@ -223,7 +237,7 @@ async function downloadTxtFile(baseUrl, fileName) {
let lastError = null;
for (const suffix of suffixes) {
const url = `${baseUrl}/data/${encodeURIComponent(fileName + suffix)}`;
const url = `${baseUrl}${pathPrefix}/data/${encodeURIComponent(fileName + suffix)}`;
try {
logger.info(`Downloading TXT: ${url}`);
const res = await axios.get(url, {
@@ -259,44 +273,58 @@ async function downloadTxtFile(baseUrl, fileName) {
* }>}
*/
async function fetchFromSquigLink(shareUrl) {
const { baseUrl, shareParam } = parseShareUrl(shareUrl);
const { baseUrl, pathPrefix, shareItems } = parseShareUrl(shareUrl);
// 并行获取 phone_book 和检测佩戴方式
const [phoneBook, detectedForm] = await Promise.all([
fetchPhoneBook(baseUrl).catch((e) => {
fetchPhoneBook(baseUrl, pathPrefix).catch((e) => {
throw new Error(`获取 phone_book.json 失败:${e.message}`);
}),
detectFormFromUrl(baseUrl),
detectFormFromUrl(baseUrl, pathPrefix),
]);
// 精确匹配
let match = findInPhoneBook(phoneBook, shareParam);
let matches = [];
if (!match) {
// 模糊匹配
matches = fuzzyFindInPhoneBook(phoneBook, shareParam);
if (matches.length === 0) {
throw new Error('在 phone_book.json 中未找到匹配的测量数据');
}
if (matches.length === 1) {
match = matches[0];
// 逐项匹配 share 参数(逗号分隔的多项中可能包含目标曲线,无匹配则跳过)
const allMatches = [];
for (const item of shareItems) {
const exact = findInPhoneBook(phoneBook, item);
if (exact) {
allMatches.push(exact);
} else {
// 多个匹配,返回候选列表让前端选择
return {
buffer: null,
fileName: null,
dataUrl: null,
brandName: '',
modelName: '',
form: detectedForm,
matches,
};
allMatches.push(...fuzzyFindInPhoneBook(phoneBook, item));
}
}
// 按 fileName 去重
const matches = [];
const seen = new Set();
for (const m of allMatches) {
if (!seen.has(m.fileName)) {
seen.add(m.fileName);
matches.push(m);
}
}
if (matches.length === 0) {
throw new Error('在 phone_book.json 中未找到匹配的测量数据');
}
if (matches.length > 1) {
// 多个匹配,返回候选列表让前端选择
return {
buffer: null,
fileName: null,
dataUrl: null,
brandName: '',
modelName: '',
form: detectedForm,
matches,
};
}
const match = matches[0];
// 下载 TXT 文件
const { buffer, url: dataUrl } = await downloadTxtFile(baseUrl, match.fileName);
const { buffer, url: dataUrl } = await downloadTxtFile(baseUrl, match.fileName, pathPrefix);
return {
buffer,
@@ -309,6 +337,75 @@ async function fetchFromSquigLink(shareUrl) {
};
}
/**
* 判断 URL 是否为直接的 TXT 频响文件链接
* @param {string} inputUrl
* @returns {boolean}
*/
function isDirectTxtUrl(inputUrl) {
try {
const url = new URL(inputUrl.trim());
const pathname = decodeURIComponent(url.pathname);
return /\.txt$/i.test(pathname);
} catch {
return false;
}
}
/**
* 直接下载 TXT 频响文件 URL(无需 phone_book 匹配)
* @param {string} inputUrl - 完整的 TXT 文件 URL
* @returns {Promise<{ buffer: Buffer, url: string, fileName: string, brandName: string, modelName: string, form: string|null }>}
*/
async function fetchDirectTxtUrl(inputUrl) {
const trimmedUrl = inputUrl.trim();
logger.info(`Direct TXT download: ${trimmedUrl}`);
const res = await axios.get(trimmedUrl, {
timeout: FETCH_TIMEOUT,
responseType: 'arraybuffer',
headers: { 'User-Agent': USER_AGENT },
});
const buffer = Buffer.from(res.data);
if (buffer.length < 10) {
throw new Error('文件内容过短,可能无效');
}
logger.info(`Direct TXT downloaded: ${trimmedUrl} (${buffer.length} bytes)`);
// 从 URL 解析文件名
const urlObj = new URL(trimmedUrl);
const decodedPath = decodeURIComponent(urlObj.pathname);
const rawFileName = decodedPath.split('/').pop() || '';
// 去掉 .txt 后缀和声道标识(L/R/L1/R2 等)
const stem = rawFileName.replace(/\.txt$/i, '').replace(/\s+[LR]\d*$/i, '').trim();
// 从文件名解析品牌和型号(第一个空格前为品牌,其余为型号)
const parts = stem.split(' ');
const brandName = parts.length > 1 ? parts[0] : '';
const modelName = parts.length > 1 ? parts.slice(1).join(' ') : stem;
// 从 URL 路径推断佩戴方式(/headphones/ → over-ear, /iems/ → in-ear 等)
let form = null;
const pathLower = decodedPath.toLowerCase();
for (const [keyword, formType] of Object.entries(TYPE_TO_FORM_MAP)) {
if (pathLower.includes(`/${keyword}/`)) {
form = formType;
break;
}
}
// 如果路径中没匹配到,尝试通过 squigsites.json 检测
if (!form) {
const baseUrl = urlObj.origin;
// 提取 pathPrefix/headphones/data/xxx.txt → /headphones
const pathSegments = urlObj.pathname.split('/').filter(Boolean);
const dataIdx = pathSegments.indexOf('data');
const pathPrefix = dataIdx > 0 ? '/' + pathSegments.slice(0, dataIdx).join('/') : '';
form = await detectFormFromUrl(baseUrl, pathPrefix);
}
return { buffer, url: trimmedUrl, fileName: stem, brandName, modelName, form };
}
module.exports = {
parseShareUrl,
fetchFromSquigLink,
@@ -318,4 +415,6 @@ module.exports = {
downloadTxtFile,
detectFormFromUrl,
getSquigSites,
isDirectTxtUrl,
fetchDirectTxtUrl,
};