|
@@ -1,6 +1,6 @@
|
|
|
/**
|
|
/**
|
|
|
* 云函数:douyinInsightManager(二阶段:爆款分析、选题池、日报)
|
|
* 云函数:douyinInsightManager(二阶段:爆款分析、选题池、日报)
|
|
|
- * actions:
|
|
|
|
|
|
|
+ * 支持 action:
|
|
|
* analysisCreate | analysisList | analysisGet | analysisUpdate
|
|
* analysisCreate | analysisList | analysisGet | analysisUpdate
|
|
|
* topicCreate | topicList | topicUpdate | topicArchive
|
|
* topicCreate | topicList | topicUpdate | topicArchive
|
|
|
* dailyReportCreate | dailyReportList | dailyReportGet
|
|
* dailyReportCreate | dailyReportList | dailyReportGet
|
|
@@ -17,7 +17,12 @@ const VOC_SOCIAL_TOKEN = readEnv('DOUYIN_API_TOKEN') || readEnv('VOC_TOKEN') ||
|
|
|
const TIKHUB_TOKEN = readEnv('TIKHUB_TOKEN') || 'gqsZHfMWgAiMwV+ITbmZy0qALADWBZVS7QnV7kKJe9CwzgWgJG+7bwK+GQ==';
|
|
const TIKHUB_TOKEN = readEnv('TIKHUB_TOKEN') || 'gqsZHfMWgAiMwV+ITbmZy0qALADWBZVS7QnV7kKJe9CwzgWgJG+7bwK+GQ==';
|
|
|
const DOUYIN_API_TOKEN = IS_TIKHUB_DIRECT ? (readEnv('DOUYIN_API_TOKEN') || TIKHUB_TOKEN) : VOC_SOCIAL_TOKEN;
|
|
const DOUYIN_API_TOKEN = IS_TIKHUB_DIRECT ? (readEnv('DOUYIN_API_TOKEN') || TIKHUB_TOKEN) : VOC_SOCIAL_TOKEN;
|
|
|
const TRANSCRIPTION_GATEWAY = (readEnv('IFLYTEK_GATEWAY_BASE_URL') || 'https://server.fmode.cn/api/apig/transcription').replace(/\/+$/, '');
|
|
const TRANSCRIPTION_GATEWAY = (readEnv('IFLYTEK_GATEWAY_BASE_URL') || 'https://server.fmode.cn/api/apig/transcription').replace(/\/+$/, '');
|
|
|
-const VOC_TOKEN = readEnv('VOC_TOKEN') || readEnv('TRANSCRIPTION_VOC_TOKEN') || readEnv('VOICE_TOKEN') || readEnv('OPENCLAW_VOC_TOKEN') || readEnv('VOC_SOCIAL_TOKEN') || LOCAL_VOC_TOKEN_FALLBACK;
|
|
|
|
|
|
|
+const TRANSCRIPTION_GATEWAY_TOKEN = readEnv('TRANSCRIPTION_VOC_TOKEN') || readEnv('VOICE_TOKEN') || readEnv('OPENCLAW_VOC_TOKEN') || readEnv('VOC_TOKEN') || readEnv('VOC_SOCIAL_TOKEN');
|
|
|
|
|
+const {
|
|
|
|
|
+ requireSession: requireParseSession,
|
|
|
|
|
+ optionalSession,
|
|
|
|
|
+ assertRequestedUserMatchesSession,
|
|
|
|
|
+} = require('./_session');
|
|
|
|
|
|
|
|
async function handler(request, response) {
|
|
async function handler(request, response) {
|
|
|
try {
|
|
try {
|
|
@@ -28,7 +33,8 @@ async function handler(request, response) {
|
|
|
|
|
|
|
|
await ensureTables();
|
|
await ensureTables();
|
|
|
|
|
|
|
|
- const session = await requireSession(request);
|
|
|
|
|
|
|
+ const session = await requireParseSession(request, Psql);
|
|
|
|
|
+ assertRequestedUserMatchesSession(request, session);
|
|
|
const requestedUserId = pickParam(request, 'userId') || '';
|
|
const requestedUserId = pickParam(request, 'userId') || '';
|
|
|
if (requestedUserId && requestedUserId !== session.userId) {
|
|
if (requestedUserId && requestedUserId !== session.userId) {
|
|
|
return response.json({ code: 403, success: false, error: '没有访问该账号数据的权限' });
|
|
return response.json({ code: 403, success: false, error: '没有访问该账号数据的权限' });
|
|
@@ -37,20 +43,20 @@ async function handler(request, response) {
|
|
|
|
|
|
|
|
if (action === 'analysisCreate') return createRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'analysis', 'data') || {});
|
|
if (action === 'analysisCreate') return createRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'analysis', 'data') || {});
|
|
|
if (action === 'analysisList') return listRows(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'limit') || 100);
|
|
if (action === 'analysisList') return listRows(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'limit') || 100);
|
|
|
- if (action === 'analysisGet') return getRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'id'));
|
|
|
|
|
- if (action === 'analysisUpdate') return updateRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'id'), pickParam(request, 'patch', 'analysis', 'data') || {});
|
|
|
|
|
|
|
+ if (action === 'analysisGet') return getRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'recordId', 'analysisId', 'id'));
|
|
|
|
|
+ if (action === 'analysisUpdate') return updateRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'recordId', 'analysisId', 'id'), pickParam(request, 'patch', 'analysis', 'data') || {});
|
|
|
|
|
|
|
|
if (action === 'topicCreate') return createRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'topic', 'data') || {});
|
|
if (action === 'topicCreate') return createRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'topic', 'data') || {});
|
|
|
if (action === 'topicList') return listRows(response, 'VideoflowTopicIdea', userId, pickParam(request, 'limit') || 500);
|
|
if (action === 'topicList') return listRows(response, 'VideoflowTopicIdea', userId, pickParam(request, 'limit') || 500);
|
|
|
- if (action === 'topicUpdate') return updateRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'id'), pickParam(request, 'patch', 'topic', 'data') || {});
|
|
|
|
|
- if (action === 'topicArchive') return updateRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'id'), { status: 'archived' });
|
|
|
|
|
|
|
+ if (action === 'topicUpdate') return updateRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'topicId', 'recordId', 'id'), pickParam(request, 'patch', 'topic', 'data') || {});
|
|
|
|
|
+ if (action === 'topicArchive') return updateRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'topicId', 'recordId', 'id'), { status: 'archived' });
|
|
|
|
|
|
|
|
if (action === 'dailyReportCreate') return createRow(response, 'VideoflowDailyReport', userId, pickParam(request, 'report', 'data') || {});
|
|
if (action === 'dailyReportCreate') return createRow(response, 'VideoflowDailyReport', userId, pickParam(request, 'report', 'data') || {});
|
|
|
if (action === 'dailyReportList') return listRows(response, 'VideoflowDailyReport', userId, pickParam(request, 'limit') || 100);
|
|
if (action === 'dailyReportList') return listRows(response, 'VideoflowDailyReport', userId, pickParam(request, 'limit') || 100);
|
|
|
- if (action === 'dailyReportGet') return getRow(response, 'VideoflowDailyReport', userId, pickParam(request, 'id'));
|
|
|
|
|
|
|
+ if (action === 'dailyReportGet') return getRow(response, 'VideoflowDailyReport', userId, pickParam(request, 'reportId', 'recordId', 'id'));
|
|
|
|
|
|
|
|
- if (action === 'transcriptStart') return startTranscript(request, response, userId);
|
|
|
|
|
- if (action === 'transcriptGet') return getTranscript(request, response, userId);
|
|
|
|
|
|
|
+ if (action === 'transcriptStart') return startTranscript(request, response, session);
|
|
|
|
|
+ if (action === 'transcriptGet') return getTranscript(request, response, session);
|
|
|
|
|
|
|
|
response.json({ code: 400, success: false, error: `未知 action: ${action}` });
|
|
response.json({ code: 400, success: false, error: `未知 action: ${action}` });
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
@@ -76,14 +82,6 @@ async function ensureTables() {
|
|
|
await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_${table.toLowerCase()}_user_biz ON "${table}" ("userId", "bizId")`);
|
|
await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_${table.toLowerCase()}_user_biz ON "${table}" ("userId", "bizId")`);
|
|
|
await Psql.query(`CREATE INDEX IF NOT EXISTS idx_${table.toLowerCase()}_user ON "${table}" ("userId")`);
|
|
await Psql.query(`CREATE INDEX IF NOT EXISTS idx_${table.toLowerCase()}_user ON "${table}" ("userId")`);
|
|
|
}
|
|
}
|
|
|
- await Psql.query(`
|
|
|
|
|
- CREATE TABLE IF NOT EXISTS "AppSession" (
|
|
|
|
|
- "token" VARCHAR(120) PRIMARY KEY,
|
|
|
|
|
- "userId" VARCHAR(50) NOT NULL,
|
|
|
|
|
- "expiresAt" TIMESTAMPTZ NOT NULL,
|
|
|
|
|
- "createdAt" TIMESTAMPTZ DEFAULT NOW()
|
|
|
|
|
- )
|
|
|
|
|
- `);
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function createRow(response, table, userId, data) {
|
|
async function createRow(response, table, userId, data) {
|
|
@@ -145,7 +143,9 @@ async function updateRow(response, table, userId, id, patch) {
|
|
|
response.json({ code: 200, success: true, data: merged });
|
|
response.json({ code: 200, success: true, data: merged });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function startTranscript(request, response, userId) {
|
|
|
|
|
|
|
+async function startTranscript(request, response, session) {
|
|
|
|
|
+ const userId = session.userId;
|
|
|
|
|
+ const transcriptionToken = transcriptionGatewayToken(session);
|
|
|
const awemeId = clean(pickParam(request, 'awemeId'));
|
|
const awemeId = clean(pickParam(request, 'awemeId'));
|
|
|
const analysisId = clean(pickParam(request, 'analysisId'));
|
|
const analysisId = clean(pickParam(request, 'analysisId'));
|
|
|
if (!awemeId) return response.json({ code: 400, success: false, error: '缺少 awemeId' });
|
|
if (!awemeId) return response.json({ code: 400, success: false, error: '缺少 awemeId' });
|
|
@@ -162,16 +162,17 @@ async function startTranscript(request, response, userId) {
|
|
|
updatedAt: now,
|
|
updatedAt: now,
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- if (!VOC_TOKEN) {
|
|
|
|
|
|
|
+ if (!transcriptionToken) {
|
|
|
job.status = 'needs_provider_config';
|
|
job.status = 'needs_provider_config';
|
|
|
- job.warnings.push('云函数未配置 VOC_TOKEN、TRANSCRIPTION_VOC_TOKEN 或 VOICE_TOKEN,无法调用转写网关。');
|
|
|
|
|
|
|
+ job.warnings.push('当前请求缺少可用于转写网关的登录态 token,请重新登录后再试。');
|
|
|
await upsertTranscriptJob(userId, job);
|
|
await upsertTranscriptJob(userId, job);
|
|
|
return response.json({ code: 200, success: true, data: job });
|
|
return response.json({ code: 200, success: true, data: job });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
- const detail = await fetchDouyinDetail(awemeId);
|
|
|
|
|
- const media = selectAudioCandidate(detail);
|
|
|
|
|
|
|
+ const detailResponse = await fetchDouyinDetail(awemeId);
|
|
|
|
|
+ const detail = findAwemeDetail(detailResponse) || detailResponse;
|
|
|
|
|
+ const media = selectMediaCandidate(detail, 'audio');
|
|
|
if (!media.url) {
|
|
if (!media.url) {
|
|
|
job.status = 'needs_media';
|
|
job.status = 'needs_media';
|
|
|
job.warnings.push('视频详情中未找到可直接提交转写的音频地址。');
|
|
job.warnings.push('视频详情中未找到可直接提交转写的音频地址。');
|
|
@@ -179,7 +180,7 @@ async function startTranscript(request, response, userId) {
|
|
|
return response.json({ code: 200, success: true, data: job });
|
|
return response.json({ code: 200, success: true, data: job });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const durationMs = media.durationMs || extractDurationMs(detail);
|
|
|
|
|
|
|
+ const durationMs = media.durationMs || durationFromDetail(detail) || extractDurationMs(detail);
|
|
|
if (!durationMs) {
|
|
if (!durationMs) {
|
|
|
job.status = 'needs_media';
|
|
job.status = 'needs_media';
|
|
|
job.warnings.push('未能确认音频时长,转写网关需要 durationMs。');
|
|
job.warnings.push('未能确认音频时长,转写网关需要 durationMs。');
|
|
@@ -187,12 +188,18 @@ async function startTranscript(request, response, userId) {
|
|
|
return response.json({ code: 200, success: true, data: job });
|
|
return response.json({ code: 200, success: true, data: job });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const uploaded = await uploadGatewayAudio(media.url, durationMs);
|
|
|
|
|
|
|
+ const uploaded = await uploadGatewayAudio(media.url, durationMs, transcriptionToken, {
|
|
|
|
|
+ sourceKind: media.kind,
|
|
|
|
|
+ keyPath: media.keyPath,
|
|
|
|
|
+ });
|
|
|
job.orderId = uploaded.orderId;
|
|
job.orderId = uploaded.orderId;
|
|
|
job.estimateTime = uploaded.estimateTime || 0;
|
|
job.estimateTime = uploaded.estimateTime || 0;
|
|
|
job.mediaUrl = media.url;
|
|
job.mediaUrl = media.url;
|
|
|
|
|
+ job.sourceKind = media.kind === 'audio' ? 'douyin_audio' : 'douyin_video';
|
|
|
|
|
+ job.mediaKeyPath = media.keyPath;
|
|
|
job.durationMs = durationMs;
|
|
job.durationMs = durationMs;
|
|
|
job.warnings.push(`已提交转写任务:${uploaded.orderId}`);
|
|
job.warnings.push(`已提交转写任务:${uploaded.orderId}`);
|
|
|
|
|
+ if (media.keyPath) job.warnings.push(`媒体来源:${media.kind || 'media'} ${media.keyPath}`);
|
|
|
await upsertTranscriptJob(userId, job);
|
|
await upsertTranscriptJob(userId, job);
|
|
|
response.json({ code: 200, success: true, data: job });
|
|
response.json({ code: 200, success: true, data: job });
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
@@ -204,8 +211,10 @@ async function startTranscript(request, response, userId) {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function getTranscript(request, response, userId) {
|
|
|
|
|
- const id = clean(pickParam(request, 'id'));
|
|
|
|
|
|
|
+async function getTranscript(request, response, session) {
|
|
|
|
|
+ const userId = session.userId;
|
|
|
|
|
+ const transcriptionToken = transcriptionGatewayToken(session);
|
|
|
|
|
+ const id = clean(pickParam(request, 'jobId', 'recordId', 'id'));
|
|
|
if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
|
|
if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
|
|
|
const rows = await Psql.query(
|
|
const rows = await Psql.query(
|
|
|
`SELECT * FROM "VideoflowTranscriptJob" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`,
|
|
`SELECT * FROM "VideoflowTranscriptJob" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`,
|
|
@@ -218,15 +227,15 @@ async function getTranscript(request, response, userId) {
|
|
|
return response.json({ code: 200, success: true, data: job });
|
|
return response.json({ code: 200, success: true, data: job });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if (!VOC_TOKEN) {
|
|
|
|
|
|
|
+ if (!transcriptionToken) {
|
|
|
job.status = 'needs_provider_config';
|
|
job.status = 'needs_provider_config';
|
|
|
- job.warnings = [...(job.warnings || []), '云函数未配置 VOC_TOKEN、TRANSCRIPTION_VOC_TOKEN 或 VOICE_TOKEN,无法查询转写网关。'];
|
|
|
|
|
|
|
+ job.warnings = [...(job.warnings || []), '当前请求缺少可用于转写网关的登录态 token,请重新登录后再试。'];
|
|
|
await upsertTranscriptJob(userId, job);
|
|
await upsertTranscriptJob(userId, job);
|
|
|
return response.json({ code: 200, success: true, data: job });
|
|
return response.json({ code: 200, success: true, data: job });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
- const data = await queryGateway(job.orderId);
|
|
|
|
|
|
|
+ const data = await queryGateway(job.orderId, transcriptionToken);
|
|
|
const status = String(gatewayValue(data, 'status') || '').toLowerCase();
|
|
const status = String(gatewayValue(data, 'status') || '').toLowerCase();
|
|
|
const text = clean(gatewayValue(data, 'text'));
|
|
const text = clean(gatewayValue(data, 'text'));
|
|
|
const segments = normalizeGatewaySegments(gatewayValue(data, 'segments'));
|
|
const segments = normalizeGatewaySegments(gatewayValue(data, 'segments'));
|
|
@@ -236,7 +245,7 @@ async function getTranscript(request, response, userId) {
|
|
|
job.segments = segments;
|
|
job.segments = segments;
|
|
|
} else if (status === 'failed' || status === 'error') {
|
|
} else if (status === 'failed' || status === 'error') {
|
|
|
job.status = 'failed';
|
|
job.status = 'failed';
|
|
|
- job.errorMessage = gatewayValue(data, 'error') || gatewayValue(data, 'message') || '转写失败';
|
|
|
|
|
|
|
+ job.errorMessage = readGatewayError(data) || '转写失败';
|
|
|
job.warnings = [...(job.warnings || []), job.errorMessage];
|
|
job.warnings = [...(job.warnings || []), job.errorMessage];
|
|
|
} else {
|
|
} else {
|
|
|
job.status = 'pending';
|
|
job.status = 'pending';
|
|
@@ -314,8 +323,9 @@ async function diagnose(request, response) {
|
|
|
isTikhubDirect: IS_TIKHUB_DIRECT,
|
|
isTikhubDirect: IS_TIKHUB_DIRECT,
|
|
|
douyinTokenConfigured: !!DOUYIN_API_TOKEN,
|
|
douyinTokenConfigured: !!DOUYIN_API_TOKEN,
|
|
|
douyinTokenSource: douyinTokenSource(),
|
|
douyinTokenSource: douyinTokenSource(),
|
|
|
- transcriptTokenConfigured: !!VOC_TOKEN,
|
|
|
|
|
- transcriptTokenSource: transcriptTokenSource(),
|
|
|
|
|
|
|
+ transcriptTokenConfigured: !!TRANSCRIPTION_GATEWAY_TOKEN,
|
|
|
|
|
+ transcriptTokenSource: transcriptTokenSource(TRANSCRIPTION_GATEWAY_TOKEN),
|
|
|
|
|
+ transcriptUsesCurrentSession: true,
|
|
|
transcriptionGateway: maskBaseUrl(TRANSCRIPTION_GATEWAY),
|
|
transcriptionGateway: maskBaseUrl(TRANSCRIPTION_GATEWAY),
|
|
|
probe: null,
|
|
probe: null,
|
|
|
}
|
|
}
|
|
@@ -354,11 +364,11 @@ async function diagnose(request, response) {
|
|
|
const sessionToken = clean(pickParam(request, 'sessionToken'));
|
|
const sessionToken = clean(pickParam(request, 'sessionToken'));
|
|
|
if (sessionToken) {
|
|
if (sessionToken) {
|
|
|
try {
|
|
try {
|
|
|
- const user = await verifyParseSessionFromDb(sessionToken) || await verifyParseSession(sessionToken);
|
|
|
|
|
|
|
+ const session = await optionalSession({ body: { sessionToken } }, Psql);
|
|
|
result.data.session = {
|
|
result.data.session = {
|
|
|
- success: !!user?.objectId,
|
|
|
|
|
- userId: user?.objectId || '',
|
|
|
|
|
- source: user?.source || '',
|
|
|
|
|
|
|
+ success: !!session?.userId,
|
|
|
|
|
+ userId: session?.userId || '',
|
|
|
|
|
+ source: 'parse-helper',
|
|
|
};
|
|
};
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
result.data.session = {
|
|
result.data.session = {
|
|
@@ -378,32 +388,156 @@ function normalizeDouyinPath(path) {
|
|
|
return path;
|
|
return path;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function selectAudioCandidate(detail) {
|
|
|
|
|
- const candidates = [];
|
|
|
|
|
- collectMediaUrls(detail, candidates, []);
|
|
|
|
|
- const audio = candidates.find(item => item.kind === 'audio') || candidates.find(item => /audio|mp4a|music|sound/i.test(item.url));
|
|
|
|
|
- return audio || { url: '', durationMs: extractDurationMs(detail) };
|
|
|
|
|
|
|
+function findAwemeDetail(node, depth = 0) {
|
|
|
|
|
+ if (!node || depth > 8) return null;
|
|
|
|
|
+ if (Array.isArray(node)) {
|
|
|
|
|
+ for (const item of node) {
|
|
|
|
|
+ const found = findAwemeDetail(item, depth + 1);
|
|
|
|
|
+ if (found) return found;
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (typeof node !== 'object') return null;
|
|
|
|
|
+ if (node.aweme_detail) return findAwemeDetail(node.aweme_detail, depth + 1) || node.aweme_detail;
|
|
|
|
|
+ if (node.aweme_info) return findAwemeDetail(node.aweme_info, depth + 1) || node.aweme_info;
|
|
|
|
|
+ if (node.aweme_id && node.video) return node;
|
|
|
|
|
+ for (const value of Object.values(node)) {
|
|
|
|
|
+ const found = findAwemeDetail(value, depth + 1);
|
|
|
|
|
+ if (found) return found;
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function inferUrlKind(pathParts, url) {
|
|
|
|
|
+ const joined = pathParts.join('.').toLowerCase();
|
|
|
|
|
+ if (/cover|poster|image|thumb|avatar|sticker/.test(joined)) return 'image';
|
|
|
|
|
+ if (/audio|mp4a|music|sound/.test(joined) || /media-audio|audio/.test(url)) return 'audio';
|
|
|
|
|
+ if (/play_addr|download_addr|bit_rate|video|media-video/.test(joined) || isLikelyMediaUrl(url)) return 'video';
|
|
|
|
|
+ return 'unknown';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function collectMediaUrls(value, out, path) {
|
|
|
|
|
- if (!value) return;
|
|
|
|
|
- if (Array.isArray(value)) {
|
|
|
|
|
- value.forEach((item, index) => collectMediaUrls(item, out, [...path, String(index)]));
|
|
|
|
|
- return;
|
|
|
|
|
|
|
+function collectMediaCandidates(node, pathParts = [], out = []) {
|
|
|
|
|
+ if (!node) return out;
|
|
|
|
|
+ if (Array.isArray(node)) {
|
|
|
|
|
+ node.forEach((item, index) => collectMediaCandidates(item, [...pathParts, String(index)], out));
|
|
|
|
|
+ return out;
|
|
|
}
|
|
}
|
|
|
- if (typeof value !== 'object') return;
|
|
|
|
|
- for (const [key, raw] of Object.entries(value)) {
|
|
|
|
|
- const keyPath = [...path, key].join('.');
|
|
|
|
|
- if (typeof raw === 'string' && /^https?:\/\//i.test(raw)) {
|
|
|
|
|
- const joined = keyPath.toLowerCase();
|
|
|
|
|
- const kind = /audio|music|sound|mp4a/.test(joined) ? 'audio' : /video|play|download|media/.test(joined) ? 'video' : '';
|
|
|
|
|
- if (kind) out.push({ url: raw, kind, keyPath, durationMs: extractDurationMs(value) });
|
|
|
|
|
- } else if (raw && typeof raw === 'object') {
|
|
|
|
|
- collectMediaUrls(raw, out, [...path, key]);
|
|
|
|
|
|
|
+ if (typeof node !== 'object') return out;
|
|
|
|
|
+
|
|
|
|
|
+ Object.entries(node).forEach(([key, value]) => {
|
|
|
|
|
+ const nextPath = [...pathParts, key];
|
|
|
|
|
+ if (key === 'url_list' && Array.isArray(value)) {
|
|
|
|
|
+ value.forEach((item, index) => {
|
|
|
|
|
+ const url = decodeMaybeBase64Url(item);
|
|
|
|
|
+ const kind = inferUrlKind(nextPath, url);
|
|
|
|
|
+ if (url && kind !== 'image') {
|
|
|
|
|
+ out.push({
|
|
|
|
|
+ url,
|
|
|
|
|
+ kind,
|
|
|
|
|
+ keyPath: nextPath.join('.'),
|
|
|
|
|
+ index,
|
|
|
|
|
+ durationMs: durationFromDetail(node),
|
|
|
|
|
+ dataSize: Number(node.data_size || node.size || 0),
|
|
|
|
|
+ bitRate: Number(node.bit_rate || node.bitrate || node.real_bitrate || node.avg_bitrate || 0),
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ } else if (['main_url', 'backup_url', 'backup_url_1', 'url'].includes(key) && typeof value === 'string') {
|
|
|
|
|
+ const url = decodeMaybeBase64Url(value);
|
|
|
|
|
+ const kind = inferUrlKind(nextPath, url);
|
|
|
|
|
+ if (url && kind !== 'image') {
|
|
|
|
|
+ out.push({
|
|
|
|
|
+ url,
|
|
|
|
|
+ kind,
|
|
|
|
|
+ keyPath: nextPath.join('.'),
|
|
|
|
|
+ durationMs: durationFromDetail(node),
|
|
|
|
|
+ dataSize: Number(node.data_size || node.size || 0),
|
|
|
|
|
+ bitRate: Number(node.bit_rate || node.bitrate || node.real_bitrate || node.avg_bitrate || 0),
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
+ collectMediaCandidates(value, nextPath, out);
|
|
|
|
|
+ });
|
|
|
|
|
+ return out;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function selectMediaCandidate(detail, preferred = 'audio') {
|
|
|
|
|
+ const candidates = collectMediaCandidates(detail)
|
|
|
|
|
+ .filter(item => ['audio', 'video'].includes(item.kind))
|
|
|
|
|
+ .filter(item => isLikelyMediaUrl(item.url));
|
|
|
|
|
+ const seen = new Set();
|
|
|
|
|
+ const unique = candidates.filter(item => {
|
|
|
|
|
+ if (seen.has(item.url)) return false;
|
|
|
|
|
+ seen.add(item.url);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ });
|
|
|
|
|
+ unique.sort((a, b) => {
|
|
|
|
|
+ const aPreferred = a.kind === preferred ? 0 : 1;
|
|
|
|
|
+ const bPreferred = b.kind === preferred ? 0 : 1;
|
|
|
|
|
+ if (aPreferred !== bPreferred) return aPreferred - bPreferred;
|
|
|
|
|
+ const rank = item => {
|
|
|
|
|
+ const keyPath = String(item.keyPath || '').toLowerCase();
|
|
|
|
|
+ if (preferred === 'audio' && item.kind === 'audio') {
|
|
|
|
|
+ if (keyPath.includes('video.dynamic_audio_list') || keyPath.includes('video.bit_rate_audio')) return 0;
|
|
|
|
|
+ if (keyPath.includes('video.')) return 1;
|
|
|
|
|
+ if (keyPath.includes('music.')) return 2;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (item.kind === preferred) return 3;
|
|
|
|
|
+ return 4;
|
|
|
|
|
+ };
|
|
|
|
|
+ const aRank = rank(a);
|
|
|
|
|
+ const bRank = rank(b);
|
|
|
|
|
+ if (aRank !== bRank) return aRank - bRank;
|
|
|
|
|
+ const aSize = a.dataSize || Number.MAX_SAFE_INTEGER;
|
|
|
|
|
+ const bSize = b.dataSize || Number.MAX_SAFE_INTEGER;
|
|
|
|
|
+ if (aSize !== bSize) return aSize - bSize;
|
|
|
|
|
+ return (a.bitRate || 0) - (b.bitRate || 0);
|
|
|
|
|
+ });
|
|
|
|
|
+ return unique[0] || { url: '', kind: '', keyPath: '', durationMs: durationFromDetail(detail) };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function isLikelyMediaUrl(value) {
|
|
|
|
|
+ if (!/^https?:\/\//i.test(String(value || ''))) return false;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsed = new URL(String(value));
|
|
|
|
|
+ const pathName = parsed.pathname.toLowerCase();
|
|
|
|
|
+ const host = parsed.hostname.toLowerCase();
|
|
|
|
|
+ if (/\.(wav|mp3|m4a|aac|flac|ogg|oga|opus|pcm|mp4|mov|m4v|webm|mkv)$/i.test(pathName)) return true;
|
|
|
|
|
+ if (host.includes('douyinvod.com')) return true;
|
|
|
|
|
+ if (host.includes('amemv.com') && pathName.includes('/aweme/v1/play')) return true;
|
|
|
|
|
+ const mime = parsed.searchParams.get('mime_type') || '';
|
|
|
|
|
+ return /audio|video_mp4|video/.test(mime);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return false;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function decodeMaybeBase64Url(value) {
|
|
|
|
|
+ const text = clean(value);
|
|
|
|
|
+ if (!text) return '';
|
|
|
|
|
+ if (/^https?:\/\//i.test(text)) return text;
|
|
|
|
|
+ if (!/^[A-Za-z0-9+/=_-]+$/.test(text) || text.length < 24) return '';
|
|
|
|
|
+ try {
|
|
|
|
|
+ const decoded = atobCompatible(text.replace(/-/g, '+').replace(/_/g, '/'));
|
|
|
|
|
+ return /^https?:\/\//i.test(decoded) ? decoded : '';
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function atobCompatible(value) {
|
|
|
|
|
+ if (typeof atob === 'function') return atob(value);
|
|
|
|
|
+ if (typeof Buffer !== 'undefined') return Buffer.from(value, 'base64').toString('utf8');
|
|
|
|
|
+ return '';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function durationFromDetail(detail) {
|
|
|
|
|
+ const raw = detail?.video?.duration || detail?.duration || detail?.video_duration || detail?.durationMs;
|
|
|
|
|
+ const numeric = Number(raw || 0);
|
|
|
|
|
+ if (!Number.isFinite(numeric) || numeric <= 0) return 0;
|
|
|
|
|
+ return numeric > 10000 ? Math.round(numeric) : Math.round(numeric * 1000);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function extractDurationMs(value) {
|
|
function extractDurationMs(value) {
|
|
|
const found = findFirstNumber(value, ['duration', 'duration_ms', 'durationMs', 'video_duration']);
|
|
const found = findFirstNumber(value, ['duration', 'duration_ms', 'durationMs', 'video_duration']);
|
|
|
if (!found) return 0;
|
|
if (!found) return 0;
|
|
@@ -431,57 +565,74 @@ function findFirstNumber(value, keys, depth = 0) {
|
|
|
return 0;
|
|
return 0;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function uploadGatewayAudio(mediaUrl, durationMs) {
|
|
|
|
|
|
|
+async function uploadGatewayAudio(mediaUrl, durationMs, token, context = {}) {
|
|
|
let mediaResp;
|
|
let mediaResp;
|
|
|
try {
|
|
try {
|
|
|
- mediaResp = await fetch(mediaUrl);
|
|
|
|
|
|
|
+ mediaResp = await fetchWithTimeout(mediaUrl, {
|
|
|
|
|
+ method: 'GET',
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ Accept: '*/*',
|
|
|
|
|
+ 'User-Agent': 'Mozilla/5.0',
|
|
|
|
|
+ Referer: 'https://www.douyin.com/',
|
|
|
|
|
+ Origin: 'https://www.douyin.com',
|
|
|
|
|
+ },
|
|
|
|
|
+ }, 60000, '下载音频');
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
throw new Error(`音频下载网络失败:${formatFetchError(error)}`);
|
|
throw new Error(`音频下载网络失败:${formatFetchError(error)}`);
|
|
|
}
|
|
}
|
|
|
if (!mediaResp.ok) throw new Error(`音频下载失败 HTTP ${mediaResp.status}`);
|
|
if (!mediaResp.ok) throw new Error(`音频下载失败 HTTP ${mediaResp.status}`);
|
|
|
- const blob = await mediaResp.blob();
|
|
|
|
|
|
|
+ const contentType = mediaResp.headers?.get?.('content-type') || '';
|
|
|
|
|
+ const arrayBuffer = await mediaResp.arrayBuffer();
|
|
|
|
|
+ const mimeType = normalizeAudioMime(contentType, mediaUrl);
|
|
|
|
|
+ const filename = `douyin-audio-${Date.now()}${extensionFromUrl(mediaUrl, mimeType)}`;
|
|
|
|
|
+ const blob = new Blob([arrayBuffer], { type: mimeType });
|
|
|
const form = new FormData();
|
|
const form = new FormData();
|
|
|
- form.append('audio', blob, `douyin-audio-${Date.now()}.m4a`);
|
|
|
|
|
|
|
+ form.append('audio', blob, filename);
|
|
|
form.append('durationMs', String(durationMs));
|
|
form.append('durationMs', String(durationMs));
|
|
|
- form.append('roleType', '1');
|
|
|
|
|
- form.append('roleNum', '0');
|
|
|
|
|
|
|
+ form.append('roleType', readEnv('IFLYTEK_IST_ROLE_TYPE') || '1');
|
|
|
|
|
+ form.append('roleNum', readEnv('IFLYTEK_IST_ROLE_NUM') || '0');
|
|
|
|
|
+ if (readEnv('IFLYTEK_IST_LANGUAGE')) form.append('language', readEnv('IFLYTEK_IST_LANGUAGE'));
|
|
|
|
|
+ if (readEnv('IFLYTEK_IST_PD')) form.append('pd', readEnv('IFLYTEK_IST_PD'));
|
|
|
let resp;
|
|
let resp;
|
|
|
try {
|
|
try {
|
|
|
- resp = await fetch(`${TRANSCRIPTION_GATEWAY}/upload`, {
|
|
|
|
|
|
|
+ resp = await fetchWithTimeout(`${TRANSCRIPTION_GATEWAY}/upload`, {
|
|
|
method: 'POST',
|
|
method: 'POST',
|
|
|
headers: {
|
|
headers: {
|
|
|
- Authorization: bearerAuth(VOC_TOKEN),
|
|
|
|
|
|
|
+ Authorization: bearerAuth(token),
|
|
|
Accept: 'application/json',
|
|
Accept: 'application/json',
|
|
|
},
|
|
},
|
|
|
body: form,
|
|
body: form,
|
|
|
- });
|
|
|
|
|
|
|
+ }, 300000, '转写网关上传');
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
- throw new Error(`转写网关上传网络失败:${formatFetchError(error)}。gateway=${maskBaseUrl(TRANSCRIPTION_GATEWAY)}`);
|
|
|
|
|
|
|
+ const mediaSource = context.keyPath ? `,media=${context.sourceKind || 'media'}:${context.keyPath}` : '';
|
|
|
|
|
+ throw new Error(`转写网关上传网络失败:${formatFetchError(error)}。gateway=${maskBaseUrl(TRANSCRIPTION_GATEWAY)},tokenSource=${transcriptTokenSource(token)},file=${filename},size=${arrayBuffer.byteLength}${mediaSource}`);
|
|
|
}
|
|
}
|
|
|
- const data = await resp.json().catch(() => ({}));
|
|
|
|
|
- if (!resp.ok || data.success === false) throw new Error(data.error?.message || data.error || data.message || `转写上传失败 HTTP ${resp.status}`);
|
|
|
|
|
|
|
+ const text = await resp.text();
|
|
|
|
|
+ const data = parseJson(text) || { rawText: text };
|
|
|
|
|
+ if (!resp.ok || data.success === false) throw new Error(readGatewayError(data) || `转写上传失败 HTTP ${resp.status}`);
|
|
|
const orderId = data.orderId || data.content?.orderId || data.data?.orderId || data.result?.orderId;
|
|
const orderId = data.orderId || data.content?.orderId || data.data?.orderId || data.result?.orderId;
|
|
|
if (!orderId) throw new Error('转写网关未返回 orderId');
|
|
if (!orderId) throw new Error('转写网关未返回 orderId');
|
|
|
return { orderId, estimateTime: Number(data.estimateTime || data.content?.estimateTime || data.data?.estimateTime || 0) };
|
|
return { orderId, estimateTime: Number(data.estimateTime || data.content?.estimateTime || data.data?.estimateTime || 0) };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function queryGateway(orderId) {
|
|
|
|
|
|
|
+async function queryGateway(orderId, token) {
|
|
|
let resp;
|
|
let resp;
|
|
|
try {
|
|
try {
|
|
|
- resp = await fetch(`${TRANSCRIPTION_GATEWAY}/result`, {
|
|
|
|
|
|
|
+ resp = await fetchWithTimeout(`${TRANSCRIPTION_GATEWAY}/result`, {
|
|
|
method: 'POST',
|
|
method: 'POST',
|
|
|
headers: {
|
|
headers: {
|
|
|
- Authorization: bearerAuth(VOC_TOKEN),
|
|
|
|
|
|
|
+ Authorization: bearerAuth(token),
|
|
|
Accept: 'application/json',
|
|
Accept: 'application/json',
|
|
|
'Content-Type': 'application/json',
|
|
'Content-Type': 'application/json',
|
|
|
},
|
|
},
|
|
|
body: JSON.stringify({ orderId }),
|
|
body: JSON.stringify({ orderId }),
|
|
|
- });
|
|
|
|
|
|
|
+ }, 120000, '转写网关查询');
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
- throw new Error(`转写网关查询网络失败:${formatFetchError(error)}。gateway=${maskBaseUrl(TRANSCRIPTION_GATEWAY)}`);
|
|
|
|
|
|
|
+ throw new Error(`转写网关查询网络失败:${formatFetchError(error)}。gateway=${maskBaseUrl(TRANSCRIPTION_GATEWAY)},tokenSource=${transcriptTokenSource(token)}`);
|
|
|
}
|
|
}
|
|
|
- const data = await resp.json().catch(() => ({}));
|
|
|
|
|
- if (!resp.ok) throw new Error(data.error?.message || data.error || data.message || `转写查询失败 HTTP ${resp.status}`);
|
|
|
|
|
|
|
+ const text = await resp.text();
|
|
|
|
|
+ const data = parseJson(text) || { rawText: text };
|
|
|
|
|
+ if (!resp.ok) throw new Error(readGatewayError(data) || `转写查询失败 HTTP ${resp.status}`);
|
|
|
return data;
|
|
return data;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -492,7 +643,16 @@ function gatewayValue(data, key) {
|
|
|
function readGatewayError(data) {
|
|
function readGatewayError(data) {
|
|
|
const detail = data?.detail;
|
|
const detail = data?.detail;
|
|
|
if (detail === 'Not Found') return '抖音数据接口地址未找到,请检查 DOUYIN_API_BASE_URL 是否配置为 https://server.fmode.cn/api/voc-social';
|
|
if (detail === 'Not Found') return '抖音数据接口地址未找到,请检查 DOUYIN_API_BASE_URL 是否配置为 https://server.fmode.cn/api/voc-social';
|
|
|
- const message = data?.mess || data?.message || data?.msg || data?.error?.message || data?.error || detail || '';
|
|
|
|
|
|
|
+ const type = clean(data?.type || data?.error?.type);
|
|
|
|
|
+ const raw = data?.mess || data?.message || data?.msg || data?.error?.message || data?.error || detail || data?.rawText || '';
|
|
|
|
|
+ const message = typeof raw === 'string'
|
|
|
|
|
+ ? raw
|
|
|
|
|
+ : raw && typeof raw === 'object'
|
|
|
|
|
+ ? JSON.stringify(raw)
|
|
|
|
|
+ : String(raw || '');
|
|
|
|
|
+ if (/unauthorized/i.test(type) || /unauthorized/i.test(message)) {
|
|
|
|
|
+ return '转写网关鉴权失败:请确认当前登录用户的 VOC/APIG 权限、余额和 session token 是否有效。';
|
|
|
|
|
+ }
|
|
|
if (/company或用户信息不存在/.test(String(message))) {
|
|
if (/company或用户信息不存在/.test(String(message))) {
|
|
|
return '当前抖音数据网关 token 未绑定有效用户或公司,请在云函数配置 DOUYIN_API_TOKEN/VOC_TOKEN/VOC_SOCIAL_TOKEN,不能使用 TikHub token。';
|
|
return '当前抖音数据网关 token 未绑定有效用户或公司,请在云函数配置 DOUYIN_API_TOKEN/VOC_TOKEN/VOC_SOCIAL_TOKEN,不能使用 TikHub token。';
|
|
|
}
|
|
}
|
|
@@ -519,6 +679,49 @@ function bearerAuth(token) {
|
|
|
return /^Bearer\s+/i.test(value) ? value : `Bearer ${value}`;
|
|
return /^Bearer\s+/i.test(value) ? value : `Bearer ${value}`;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+async function fetchWithTimeout(url, init, timeoutMs, label) {
|
|
|
|
|
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
|
|
|
+ const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
|
|
|
+ try {
|
|
|
|
|
+ return await fetch(url, {
|
|
|
|
|
+ ...init,
|
|
|
|
|
+ signal: controller ? controller.signal : init?.signal,
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ if (error && error.name === 'AbortError') {
|
|
|
|
|
+ throw new Error(`${label || '请求'}超时(${timeoutMs}ms)`);
|
|
|
|
|
+ }
|
|
|
|
|
+ throw error;
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (timer) clearTimeout(timer);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function normalizeAudioMime(contentType, url) {
|
|
|
|
|
+ const value = clean(contentType).split(';')[0].toLowerCase();
|
|
|
|
|
+ if (/^audio\//.test(value) || value === 'video/mp4') return value;
|
|
|
|
|
+ const pathname = (() => {
|
|
|
|
|
+ try { return new URL(url).pathname.toLowerCase(); } catch { return String(url || '').toLowerCase(); }
|
|
|
|
|
+ })();
|
|
|
|
|
+ if (pathname.endsWith('.mp3')) return 'audio/mpeg';
|
|
|
|
|
+ if (pathname.endsWith('.wav')) return 'audio/wav';
|
|
|
|
|
+ if (pathname.endsWith('.aac')) return 'audio/aac';
|
|
|
|
|
+ if (pathname.endsWith('.m4a')) return 'audio/mp4';
|
|
|
|
|
+ return 'audio/mp4';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function extensionFromUrl(url, mimeType) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const ext = new URL(url).pathname.match(/\.(mp3|m4a|aac|wav|mp4|mov|webm)$/i)?.[0];
|
|
|
|
|
+ if (ext) return ext.toLowerCase();
|
|
|
|
|
+ } catch {}
|
|
|
|
|
+ const mime = clean(mimeType).toLowerCase();
|
|
|
|
|
+ if (mime.includes('mpeg')) return '.mp3';
|
|
|
|
|
+ if (mime.includes('wav')) return '.wav';
|
|
|
|
|
+ if (mime.includes('aac')) return '.aac';
|
|
|
|
|
+ return '.m4a';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function douyinTokenSource() {
|
|
function douyinTokenSource() {
|
|
|
if (readEnv('DOUYIN_API_TOKEN')) return 'DOUYIN_API_TOKEN';
|
|
if (readEnv('DOUYIN_API_TOKEN')) return 'DOUYIN_API_TOKEN';
|
|
|
if (readEnv('VOC_TOKEN')) return 'VOC_TOKEN';
|
|
if (readEnv('VOC_TOKEN')) return 'VOC_TOKEN';
|
|
@@ -531,16 +734,29 @@ function douyinTokenSource() {
|
|
|
return IS_TIKHUB_DIRECT ? 'TIKHUB_TOKEN_FALLBACK' : '';
|
|
return IS_TIKHUB_DIRECT ? 'TIKHUB_TOKEN_FALLBACK' : '';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function transcriptTokenSource() {
|
|
|
|
|
- if (readEnv('VOC_TOKEN')) return 'VOC_TOKEN';
|
|
|
|
|
|
|
+function transcriptionGatewayToken(session) {
|
|
|
|
|
+ return clean(session?.sessionToken) || clean(TRANSCRIPTION_GATEWAY_TOKEN);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function transcriptTokenSource(token) {
|
|
|
|
|
+ if (token && clean(token).startsWith('r:')) return 'current_user_sessionToken';
|
|
|
if (readEnv('TRANSCRIPTION_VOC_TOKEN')) return 'TRANSCRIPTION_VOC_TOKEN';
|
|
if (readEnv('TRANSCRIPTION_VOC_TOKEN')) return 'TRANSCRIPTION_VOC_TOKEN';
|
|
|
if (readEnv('VOICE_TOKEN')) return 'VOICE_TOKEN';
|
|
if (readEnv('VOICE_TOKEN')) return 'VOICE_TOKEN';
|
|
|
if (readEnv('OPENCLAW_VOC_TOKEN')) return 'OPENCLAW_VOC_TOKEN';
|
|
if (readEnv('OPENCLAW_VOC_TOKEN')) return 'OPENCLAW_VOC_TOKEN';
|
|
|
|
|
+ if (readEnv('VOC_TOKEN')) return 'VOC_TOKEN';
|
|
|
if (readEnv('VOC_SOCIAL_TOKEN')) return 'VOC_SOCIAL_TOKEN';
|
|
if (readEnv('VOC_SOCIAL_TOKEN')) return 'VOC_SOCIAL_TOKEN';
|
|
|
- if (LOCAL_VOC_TOKEN_FALLBACK) return 'LOCAL_VOC_TOKEN_FALLBACK';
|
|
|
|
|
|
|
+ if (clean(token)) return 'TRANSCRIPTION_GATEWAY_TOKEN';
|
|
|
return '';
|
|
return '';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function parseJson(text) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return text ? JSON.parse(text) : null;
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function formatFetchError(error) {
|
|
function formatFetchError(error) {
|
|
|
const message = error && error.message ? error.message : String(error || 'fetch failed');
|
|
const message = error && error.message ? error.message : String(error || 'fetch failed');
|
|
|
const cause = error && error.cause ? `;cause=${error.cause.code || error.cause.message || error.cause}` : '';
|
|
const cause = error && error.cause ? `;cause=${error.cause.code || error.cause.message || error.cause}` : '';
|
|
@@ -563,65 +779,6 @@ function pickParam(request, ...names) {
|
|
|
return null;
|
|
return null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function requireSession(request) {
|
|
|
|
|
- const token = clean(pickParam(request, 'sessionToken'));
|
|
|
|
|
- if (!token) throw new Error('请先登录');
|
|
|
|
|
- const rows = await Psql.query(
|
|
|
|
|
- `SELECT * FROM "AppSession" WHERE "token"=$1 AND "expiresAt" > NOW() LIMIT 1`,
|
|
|
|
|
- [token]
|
|
|
|
|
- );
|
|
|
|
|
- if (rows.length) return rows[0];
|
|
|
|
|
-
|
|
|
|
|
- const parseUser = await verifyParseSessionFromDb(token) || await verifyParseSession(token);
|
|
|
|
|
- if (parseUser?.objectId) return { token, userId: parseUser.objectId, source: 'parse' };
|
|
|
|
|
- throw new Error('登录已过期,请重新登录');
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-async function verifyParseSessionFromDb(sessionToken) {
|
|
|
|
|
- try {
|
|
|
|
|
- const rows = await Psql.query(
|
|
|
|
|
- `SELECT s."objectId" AS "sessionObjectId",
|
|
|
|
|
- s."sessionToken" AS "sessionToken",
|
|
|
|
|
- s."expiresAt" AS "expiresAt",
|
|
|
|
|
- s."_p_user" AS "userPointer",
|
|
|
|
|
- u."objectId" AS "objectId",
|
|
|
|
|
- u."username" AS "username"
|
|
|
|
|
- FROM "_Session" s
|
|
|
|
|
- LEFT JOIN "_User" u ON s."_p_user" = CONCAT('_User$', u."objectId")
|
|
|
|
|
- WHERE s."sessionToken"=$1
|
|
|
|
|
- AND (s."expiresAt" IS NULL OR s."expiresAt" > NOW())
|
|
|
|
|
- LIMIT 1`,
|
|
|
|
|
- [sessionToken]
|
|
|
|
|
- );
|
|
|
|
|
- if (!rows.length) return null;
|
|
|
|
|
- const row = rows[0];
|
|
|
|
|
- const pointerUserId = String(row.userPointer || '').replace(/^_User\$/, '');
|
|
|
|
|
- const objectId = row.objectId || pointerUserId;
|
|
|
|
|
- return objectId ? { objectId, username: row.username || '', source: 'parse-db' } : null;
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.warn('verifyParseSessionFromDb skipped:', error.message);
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-async function verifyParseSession(sessionToken) {
|
|
|
|
|
- if (typeof fetch !== 'function') return null;
|
|
|
|
|
- try {
|
|
|
|
|
- const resp = await fetch(`${PARSE_API_HOST}/parse/users/me?include=company`, {
|
|
|
|
|
- method: 'GET',
|
|
|
|
|
- headers: {
|
|
|
|
|
- 'X-Parse-Application-Id': PARSE_APP_ID,
|
|
|
|
|
- 'X-Parse-Session-Token': sessionToken,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- const data = await resp.json().catch(() => ({}));
|
|
|
|
|
- if (!resp.ok) throw new Error(data.error || data.message || `HTTP ${resp.status}`);
|
|
|
|
|
- return data.objectId ? { ...data, source: 'parse-rest' } : null;
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- throw new Error(`Parse 会话网络校验失败:${formatFetchError(error)}`);
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
function clean(value) {
|
|
function clean(value) {
|
|
|
return String(value || '').trim();
|
|
return String(value || '').trim();
|
|
|
}
|
|
}
|
|
@@ -639,3 +796,17 @@ function readEnv(name) {
|
|
|
}
|
|
}
|
|
|
return '';
|
|
return '';
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+if (typeof module !== 'undefined' && module.exports && readEnv('NODE_ENV') === 'test') {
|
|
|
|
|
+ module.exports = {
|
|
|
|
|
+ __test: {
|
|
|
|
|
+ findAwemeDetail,
|
|
|
|
|
+ selectMediaCandidate,
|
|
|
|
|
+ decodeMaybeBase64Url,
|
|
|
|
|
+ durationFromDetail,
|
|
|
|
|
+ transcriptionGatewayToken,
|
|
|
|
|
+ transcriptTokenSource,
|
|
|
|
|
+ readGatewayError,
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+}
|