优化新增耳机流程,优化界面
This commit is contained in:
@@ -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 名解析品牌和型号
|
||||
|
||||
@@ -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.txt(DIR 通常为 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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user