13-douyinInsightManager.js 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812
  1. /**
  2. * 云函数:douyinInsightManager(二阶段:爆款分析、选题池、日报)
  3. * 支持 action:
  4. * analysisCreate | analysisList | analysisGet | analysisUpdate
  5. * topicCreate | topicList | topicUpdate | topicArchive
  6. * dailyReportCreate | dailyReportList | dailyReportGet
  7. * transcriptStart | transcriptGet
  8. *
  9. * 说明:本函数只负责业务资产的持久化和账号隔离。抖音原始数据抓取仍由 12-douyinManager 负责。
  10. */
  11. const PARSE_API_HOST = readEnv('PARSE_API_HOST') || 'https://server.fmode.cn';
  12. const PARSE_APP_ID = readEnv('PARSE_APP_ID') || 'ncloudmaster';
  13. const DOUYIN_API_BASE_URL = (readEnv('DOUYIN_API_BASE_URL') || readEnv('VOC_SOCIAL_BASE_URL') || 'https://server.fmode.cn/api/voc-social').replace(/\/+$/, '');
  14. const IS_TIKHUB_DIRECT = /api\.tikhub\.io/i.test(DOUYIN_API_BASE_URL);
  15. const LOCAL_VOC_TOKEN_FALLBACK = 'r:33c57d404c8fffc9b19199a4da0bb663';
  16. const VOC_SOCIAL_TOKEN = readEnv('DOUYIN_API_TOKEN') || readEnv('VOC_TOKEN') || readEnv('TRANSCRIPTION_VOC_TOKEN') || readEnv('VOICE_TOKEN') || readEnv('OPENCLAW_VOC_TOKEN') || readEnv('VOC_SOCIAL_TOKEN') || LOCAL_VOC_TOKEN_FALLBACK;
  17. const TIKHUB_TOKEN = readEnv('TIKHUB_TOKEN') || 'gqsZHfMWgAiMwV+ITbmZy0qALADWBZVS7QnV7kKJe9CwzgWgJG+7bwK+GQ==';
  18. const DOUYIN_API_TOKEN = IS_TIKHUB_DIRECT ? (readEnv('DOUYIN_API_TOKEN') || TIKHUB_TOKEN) : VOC_SOCIAL_TOKEN;
  19. const TRANSCRIPTION_GATEWAY = (readEnv('IFLYTEK_GATEWAY_BASE_URL') || 'https://server.fmode.cn/api/apig/transcription').replace(/\/+$/, '');
  20. const TRANSCRIPTION_GATEWAY_TOKEN = readEnv('TRANSCRIPTION_VOC_TOKEN') || readEnv('VOICE_TOKEN') || readEnv('OPENCLAW_VOC_TOKEN') || readEnv('VOC_TOKEN') || readEnv('VOC_SOCIAL_TOKEN');
  21. const {
  22. requireSession: requireParseSession,
  23. optionalSession,
  24. assertRequestedUserMatchesSession,
  25. } = require('./_session');
  26. async function handler(request, response) {
  27. try {
  28. const action = pickParam(request, 'action') || '';
  29. if (action === 'diagnose') {
  30. return diagnose(request, response);
  31. }
  32. await ensureTables();
  33. const session = await requireParseSession(request, Psql);
  34. assertRequestedUserMatchesSession(request, session);
  35. const requestedUserId = pickParam(request, 'userId') || '';
  36. if (requestedUserId && requestedUserId !== session.userId) {
  37. return response.json({ code: 403, success: false, error: '没有访问该账号数据的权限' });
  38. }
  39. const userId = session.userId;
  40. if (action === 'analysisCreate') return createRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'analysis', 'data') || {});
  41. if (action === 'analysisList') return listRows(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'limit') || 100);
  42. if (action === 'analysisGet') return getRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'recordId', 'analysisId', 'id'));
  43. if (action === 'analysisUpdate') return updateRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'recordId', 'analysisId', 'id'), pickParam(request, 'patch', 'analysis', 'data') || {});
  44. if (action === 'topicCreate') return createRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'topic', 'data') || {});
  45. if (action === 'topicList') return listRows(response, 'VideoflowTopicIdea', userId, pickParam(request, 'limit') || 500);
  46. if (action === 'topicUpdate') return updateRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'topicId', 'recordId', 'id'), pickParam(request, 'patch', 'topic', 'data') || {});
  47. if (action === 'topicArchive') return updateRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'topicId', 'recordId', 'id'), { status: 'archived' });
  48. if (action === 'dailyReportCreate') return createRow(response, 'VideoflowDailyReport', userId, pickParam(request, 'report', 'data') || {});
  49. if (action === 'dailyReportList') return listRows(response, 'VideoflowDailyReport', userId, pickParam(request, 'limit') || 100);
  50. if (action === 'dailyReportGet') return getRow(response, 'VideoflowDailyReport', userId, pickParam(request, 'reportId', 'recordId', 'id'));
  51. if (action === 'transcriptStart') return startTranscript(request, response, session);
  52. if (action === 'transcriptGet') return getTranscript(request, response, session);
  53. response.json({ code: 400, success: false, error: `未知 action: ${action}` });
  54. } catch (error) {
  55. console.error('douyinInsightManager failed:', error.message);
  56. response.json({ code: 500, success: false, error: error.message });
  57. }
  58. }
  59. async function ensureTables() {
  60. for (const table of ['VideoflowViralAnalysis', 'VideoflowTopicIdea', 'VideoflowDailyReport', 'VideoflowTranscriptJob']) {
  61. await Psql.query(`
  62. CREATE TABLE IF NOT EXISTS "${table}" (
  63. "objectId" VARCHAR(50) PRIMARY KEY,
  64. "bizId" VARCHAR(255) NOT NULL,
  65. "userId" VARCHAR(255) NOT NULL,
  66. "data" JSONB NOT NULL DEFAULT '{}',
  67. "status" VARCHAR(50) DEFAULT '',
  68. "createdAt" TIMESTAMPTZ DEFAULT NOW(),
  69. "updatedAt" TIMESTAMPTZ DEFAULT NOW()
  70. )
  71. `);
  72. await Psql.query(`DROP INDEX IF EXISTS idx_${table.toLowerCase()}_biz`);
  73. await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_${table.toLowerCase()}_user_biz ON "${table}" ("userId", "bizId")`);
  74. await Psql.query(`CREATE INDEX IF NOT EXISTS idx_${table.toLowerCase()}_user ON "${table}" ("userId")`);
  75. }
  76. }
  77. async function createRow(response, table, userId, data) {
  78. const now = new Date().toISOString();
  79. const bizId = data.id || data.bizId || generateId();
  80. const merged = { ...data, id: bizId, userId, createdAt: data.createdAt || now, updatedAt: now };
  81. const existing = await Psql.query(
  82. `SELECT * FROM "${table}" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`,
  83. [bizId, userId]
  84. );
  85. if (existing.length) {
  86. await Psql.query(
  87. `UPDATE "${table}" SET "data"=$1, "status"=$2, "updatedAt"=NOW() WHERE "bizId"=$3 AND "userId"=$4`,
  88. [JSON.stringify(merged), merged.status || '', bizId, userId]
  89. );
  90. return response.json({ code: 200, success: true, data: merged });
  91. }
  92. await Psql.query(
  93. `INSERT INTO "${table}" ("objectId","bizId","userId","data","status")
  94. VALUES ($1,$2,$3,$4,$5)`,
  95. [generateId(), bizId, userId, JSON.stringify(merged), merged.status || '']
  96. );
  97. response.json({ code: 200, success: true, data: merged });
  98. }
  99. async function listRows(response, table, userId, limit) {
  100. const parsedLimit = parseInt(limit || '100', 10);
  101. const safeLimit = Number.isFinite(parsedLimit) ? Math.min(Math.max(parsedLimit, 1), 1000) : 100;
  102. const rows = await Psql.query(
  103. `SELECT * FROM "${table}" WHERE "userId"=$1 ORDER BY "updatedAt" DESC LIMIT $2`,
  104. [userId, safeLimit]
  105. );
  106. response.json({ code: 200, success: true, data: rows.map(rowToObj) });
  107. }
  108. async function getRow(response, table, userId, id) {
  109. if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
  110. const rows = await Psql.query(
  111. `SELECT * FROM "${table}" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`,
  112. [id, userId]
  113. );
  114. if (!rows.length) return response.json({ code: 404, success: false, error: '未找到记录' });
  115. response.json({ code: 200, success: true, data: rowToObj(rows[0]) });
  116. }
  117. async function updateRow(response, table, userId, id, patch) {
  118. if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
  119. const rows = await Psql.query(
  120. `SELECT * FROM "${table}" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`,
  121. [id, userId]
  122. );
  123. if (!rows.length) return response.json({ code: 404, success: false, error: '未找到记录' });
  124. const merged = { ...rowToObj(rows[0]), ...patch, id, userId, updatedAt: new Date().toISOString() };
  125. await Psql.query(
  126. `UPDATE "${table}" SET "data"=$1, "status"=$2, "updatedAt"=NOW() WHERE "bizId"=$3 AND "userId"=$4`,
  127. [JSON.stringify(merged), merged.status || '', id, userId]
  128. );
  129. response.json({ code: 200, success: true, data: merged });
  130. }
  131. async function startTranscript(request, response, session) {
  132. const userId = session.userId;
  133. const transcriptionToken = transcriptionGatewayToken(session);
  134. const awemeId = clean(pickParam(request, 'awemeId'));
  135. const analysisId = clean(pickParam(request, 'analysisId'));
  136. if (!awemeId) return response.json({ code: 400, success: false, error: '缺少 awemeId' });
  137. const now = new Date().toISOString();
  138. const job = {
  139. id: `transcript_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
  140. awemeId,
  141. analysisId,
  142. provider: 'iflytek-gateway',
  143. status: 'pending',
  144. warnings: [],
  145. createdAt: now,
  146. updatedAt: now,
  147. };
  148. if (!transcriptionToken) {
  149. job.status = 'needs_provider_config';
  150. job.warnings.push('当前请求缺少可用于转写网关的登录态 token,请重新登录后再试。');
  151. await upsertTranscriptJob(userId, job);
  152. return response.json({ code: 200, success: true, data: job });
  153. }
  154. try {
  155. const detailResponse = await fetchDouyinDetail(awemeId);
  156. const detail = findAwemeDetail(detailResponse) || detailResponse;
  157. const media = selectMediaCandidate(detail, 'audio');
  158. if (!media.url) {
  159. job.status = 'needs_media';
  160. job.warnings.push('视频详情中未找到可直接提交转写的音频地址。');
  161. await upsertTranscriptJob(userId, job);
  162. return response.json({ code: 200, success: true, data: job });
  163. }
  164. const durationMs = media.durationMs || durationFromDetail(detail) || extractDurationMs(detail);
  165. if (!durationMs) {
  166. job.status = 'needs_media';
  167. job.warnings.push('未能确认音频时长,转写网关需要 durationMs。');
  168. await upsertTranscriptJob(userId, job);
  169. return response.json({ code: 200, success: true, data: job });
  170. }
  171. const uploaded = await uploadGatewayAudio(media.url, durationMs, transcriptionToken, {
  172. sourceKind: media.kind,
  173. keyPath: media.keyPath,
  174. });
  175. job.orderId = uploaded.orderId;
  176. job.estimateTime = uploaded.estimateTime || 0;
  177. job.mediaUrl = media.url;
  178. job.sourceKind = media.kind === 'audio' ? 'douyin_audio' : 'douyin_video';
  179. job.mediaKeyPath = media.keyPath;
  180. job.durationMs = durationMs;
  181. job.warnings.push(`已提交转写任务:${uploaded.orderId}`);
  182. if (media.keyPath) job.warnings.push(`媒体来源:${media.kind || 'media'} ${media.keyPath}`);
  183. await upsertTranscriptJob(userId, job);
  184. response.json({ code: 200, success: true, data: job });
  185. } catch (error) {
  186. job.status = 'failed';
  187. job.errorMessage = error.message || '提交逐字稿任务失败';
  188. job.warnings.push(job.errorMessage);
  189. await upsertTranscriptJob(userId, job);
  190. response.json({ code: 200, success: true, data: job });
  191. }
  192. }
  193. async function getTranscript(request, response, session) {
  194. const userId = session.userId;
  195. const transcriptionToken = transcriptionGatewayToken(session);
  196. const id = clean(pickParam(request, 'jobId', 'recordId', 'id'));
  197. if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
  198. const rows = await Psql.query(
  199. `SELECT * FROM "VideoflowTranscriptJob" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`,
  200. [id, userId]
  201. );
  202. if (!rows.length) return response.json({ code: 404, success: false, error: '未找到逐字稿任务' });
  203. const job = rowToObj(rows[0]);
  204. if (!job.orderId || job.status === 'completed' || job.status === 'failed') {
  205. return response.json({ code: 200, success: true, data: job });
  206. }
  207. if (!transcriptionToken) {
  208. job.status = 'needs_provider_config';
  209. job.warnings = [...(job.warnings || []), '当前请求缺少可用于转写网关的登录态 token,请重新登录后再试。'];
  210. await upsertTranscriptJob(userId, job);
  211. return response.json({ code: 200, success: true, data: job });
  212. }
  213. try {
  214. const data = await queryGateway(job.orderId, transcriptionToken);
  215. const status = String(gatewayValue(data, 'status') || '').toLowerCase();
  216. const text = clean(gatewayValue(data, 'text'));
  217. const segments = normalizeGatewaySegments(gatewayValue(data, 'segments'));
  218. if (status === 'completed' || text) {
  219. job.status = 'completed';
  220. job.text = text || segments.map(s => s.text).join('\n');
  221. job.segments = segments;
  222. } else if (status === 'failed' || status === 'error') {
  223. job.status = 'failed';
  224. job.errorMessage = readGatewayError(data) || '转写失败';
  225. job.warnings = [...(job.warnings || []), job.errorMessage];
  226. } else {
  227. job.status = 'pending';
  228. job.warnings = [...new Set([...(job.warnings || []), '转写任务仍在处理中。'])];
  229. }
  230. job.updatedAt = new Date().toISOString();
  231. await upsertTranscriptJob(userId, job);
  232. response.json({ code: 200, success: true, data: job });
  233. } catch (error) {
  234. job.status = 'failed';
  235. job.errorMessage = error.message || '查询逐字稿任务失败';
  236. job.warnings = [...(job.warnings || []), job.errorMessage];
  237. job.updatedAt = new Date().toISOString();
  238. await upsertTranscriptJob(userId, job);
  239. response.json({ code: 200, success: true, data: job });
  240. }
  241. }
  242. async function upsertTranscriptJob(userId, job) {
  243. const existing = await Psql.query(
  244. `SELECT * FROM "VideoflowTranscriptJob" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`,
  245. [job.id, userId]
  246. );
  247. if (existing.length) {
  248. await Psql.query(
  249. `UPDATE "VideoflowTranscriptJob" SET "data"=$1, "status"=$2, "updatedAt"=NOW() WHERE "bizId"=$3 AND "userId"=$4`,
  250. [JSON.stringify(job), job.status || '', job.id, userId]
  251. );
  252. return;
  253. }
  254. await Psql.query(
  255. `INSERT INTO "VideoflowTranscriptJob" ("objectId","bizId","userId","data","status")
  256. VALUES ($1,$2,$3,$4,$5)`,
  257. [generateId(), job.id, userId, JSON.stringify(job), job.status || '']
  258. );
  259. }
  260. function rowToObj(row) {
  261. const data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
  262. return { ...data, objectId: row.objectId, createdAt: row.createdAt, updatedAt: row.updatedAt };
  263. }
  264. async function fetchDouyinDetail(awemeId) {
  265. if (!DOUYIN_API_TOKEN) {
  266. throw new Error('抖音数据网关未配置有效 token:FMode voc-social 请配置 DOUYIN_API_TOKEN、VOC_TOKEN 或 VOC_SOCIAL_TOKEN;TikHub 直连才使用 TIKHUB_TOKEN。');
  267. }
  268. const url = new URL(`${DOUYIN_API_BASE_URL}${normalizeDouyinPath('/douyin/app/v3/fetch_one_video_v3')}`);
  269. url.searchParams.set('aweme_id', awemeId);
  270. let resp;
  271. try {
  272. resp = await fetch(url.toString(), {
  273. method: 'GET',
  274. headers: {
  275. 'Accept': 'application/json',
  276. 'Authorization': bearerAuth(DOUYIN_API_TOKEN),
  277. },
  278. });
  279. } catch (error) {
  280. throw new Error(`抖音数据网关网络请求失败:${formatFetchError(error)}。base=${maskBaseUrl(DOUYIN_API_BASE_URL)} route=/douyin/app/v3/fetch_one_video_v3`);
  281. }
  282. const data = await resp.json().catch(() => ({}));
  283. if (!resp.ok || data.success === false) throw new Error(readGatewayError(data) || `抖音详情获取失败 HTTP ${resp.status}`);
  284. return data;
  285. }
  286. async function diagnose(request, response) {
  287. const awemeId = String(pickParam(request, 'awemeId') || '7592116912205630761').trim();
  288. const result = {
  289. code: 200,
  290. success: true,
  291. data: {
  292. parseApiHost: PARSE_API_HOST,
  293. douyinBaseUrl: maskBaseUrl(DOUYIN_API_BASE_URL),
  294. isTikhubDirect: IS_TIKHUB_DIRECT,
  295. douyinTokenConfigured: !!DOUYIN_API_TOKEN,
  296. douyinTokenSource: douyinTokenSource(),
  297. transcriptTokenConfigured: !!TRANSCRIPTION_GATEWAY_TOKEN,
  298. transcriptTokenSource: transcriptTokenSource(TRANSCRIPTION_GATEWAY_TOKEN),
  299. transcriptUsesCurrentSession: true,
  300. transcriptionGateway: maskBaseUrl(TRANSCRIPTION_GATEWAY),
  301. probe: null,
  302. }
  303. };
  304. if (String(pickParam(request, 'probe') || '') === '1') {
  305. try {
  306. const detail = await fetchDouyinDetail(awemeId);
  307. result.data.probe = {
  308. code: detail?.code,
  309. success: detail?.success !== false,
  310. hasData: !!detail?.data,
  311. keys: detail && typeof detail === 'object' ? Object.keys(detail).slice(0, 10) : [],
  312. };
  313. } catch (error) {
  314. result.data.probe = {
  315. code: 502,
  316. success: false,
  317. error: error && error.message ? error.message : String(error || 'probe failed'),
  318. };
  319. }
  320. }
  321. if (String(pickParam(request, 'probeDb') || '') === '1') {
  322. try {
  323. await ensureTables();
  324. result.data.database = { success: true };
  325. } catch (error) {
  326. result.data.database = {
  327. success: false,
  328. error: error && error.message ? error.message : String(error || 'database probe failed'),
  329. };
  330. }
  331. }
  332. const sessionToken = clean(pickParam(request, 'sessionToken'));
  333. if (sessionToken) {
  334. try {
  335. const session = await optionalSession({ body: { sessionToken } }, Psql);
  336. result.data.session = {
  337. success: !!session?.userId,
  338. userId: session?.userId || '',
  339. source: 'parse-helper',
  340. };
  341. } catch (error) {
  342. result.data.session = {
  343. success: false,
  344. error: error && error.message ? error.message : String(error || 'session probe failed'),
  345. };
  346. }
  347. }
  348. return response.json(result);
  349. }
  350. function normalizeDouyinPath(path) {
  351. if (IS_TIKHUB_DIRECT && !path.startsWith('/api/v1/')) {
  352. return `/api/v1${path}`;
  353. }
  354. return path;
  355. }
  356. function findAwemeDetail(node, depth = 0) {
  357. if (!node || depth > 8) return null;
  358. if (Array.isArray(node)) {
  359. for (const item of node) {
  360. const found = findAwemeDetail(item, depth + 1);
  361. if (found) return found;
  362. }
  363. return null;
  364. }
  365. if (typeof node !== 'object') return null;
  366. if (node.aweme_detail) return findAwemeDetail(node.aweme_detail, depth + 1) || node.aweme_detail;
  367. if (node.aweme_info) return findAwemeDetail(node.aweme_info, depth + 1) || node.aweme_info;
  368. if (node.aweme_id && node.video) return node;
  369. for (const value of Object.values(node)) {
  370. const found = findAwemeDetail(value, depth + 1);
  371. if (found) return found;
  372. }
  373. return null;
  374. }
  375. function inferUrlKind(pathParts, url) {
  376. const joined = pathParts.join('.').toLowerCase();
  377. if (/cover|poster|image|thumb|avatar|sticker/.test(joined)) return 'image';
  378. if (/audio|mp4a|music|sound/.test(joined) || /media-audio|audio/.test(url)) return 'audio';
  379. if (/play_addr|download_addr|bit_rate|video|media-video/.test(joined) || isLikelyMediaUrl(url)) return 'video';
  380. return 'unknown';
  381. }
  382. function collectMediaCandidates(node, pathParts = [], out = []) {
  383. if (!node) return out;
  384. if (Array.isArray(node)) {
  385. node.forEach((item, index) => collectMediaCandidates(item, [...pathParts, String(index)], out));
  386. return out;
  387. }
  388. if (typeof node !== 'object') return out;
  389. Object.entries(node).forEach(([key, value]) => {
  390. const nextPath = [...pathParts, key];
  391. if (key === 'url_list' && Array.isArray(value)) {
  392. value.forEach((item, index) => {
  393. const url = decodeMaybeBase64Url(item);
  394. const kind = inferUrlKind(nextPath, url);
  395. if (url && kind !== 'image') {
  396. out.push({
  397. url,
  398. kind,
  399. keyPath: nextPath.join('.'),
  400. index,
  401. durationMs: durationFromDetail(node),
  402. dataSize: Number(node.data_size || node.size || 0),
  403. bitRate: Number(node.bit_rate || node.bitrate || node.real_bitrate || node.avg_bitrate || 0),
  404. });
  405. }
  406. });
  407. } else if (['main_url', 'backup_url', 'backup_url_1', 'url'].includes(key) && typeof value === 'string') {
  408. const url = decodeMaybeBase64Url(value);
  409. const kind = inferUrlKind(nextPath, url);
  410. if (url && kind !== 'image') {
  411. out.push({
  412. url,
  413. kind,
  414. keyPath: nextPath.join('.'),
  415. durationMs: durationFromDetail(node),
  416. dataSize: Number(node.data_size || node.size || 0),
  417. bitRate: Number(node.bit_rate || node.bitrate || node.real_bitrate || node.avg_bitrate || 0),
  418. });
  419. }
  420. }
  421. collectMediaCandidates(value, nextPath, out);
  422. });
  423. return out;
  424. }
  425. function selectMediaCandidate(detail, preferred = 'audio') {
  426. const candidates = collectMediaCandidates(detail)
  427. .filter(item => ['audio', 'video'].includes(item.kind))
  428. .filter(item => isLikelyMediaUrl(item.url));
  429. const seen = new Set();
  430. const unique = candidates.filter(item => {
  431. if (seen.has(item.url)) return false;
  432. seen.add(item.url);
  433. return true;
  434. });
  435. unique.sort((a, b) => {
  436. const aPreferred = a.kind === preferred ? 0 : 1;
  437. const bPreferred = b.kind === preferred ? 0 : 1;
  438. if (aPreferred !== bPreferred) return aPreferred - bPreferred;
  439. const rank = item => {
  440. const keyPath = String(item.keyPath || '').toLowerCase();
  441. if (preferred === 'audio' && item.kind === 'audio') {
  442. if (keyPath.includes('video.dynamic_audio_list') || keyPath.includes('video.bit_rate_audio')) return 0;
  443. if (keyPath.includes('video.')) return 1;
  444. if (keyPath.includes('music.')) return 2;
  445. }
  446. if (item.kind === preferred) return 3;
  447. return 4;
  448. };
  449. const aRank = rank(a);
  450. const bRank = rank(b);
  451. if (aRank !== bRank) return aRank - bRank;
  452. const aSize = a.dataSize || Number.MAX_SAFE_INTEGER;
  453. const bSize = b.dataSize || Number.MAX_SAFE_INTEGER;
  454. if (aSize !== bSize) return aSize - bSize;
  455. return (a.bitRate || 0) - (b.bitRate || 0);
  456. });
  457. return unique[0] || { url: '', kind: '', keyPath: '', durationMs: durationFromDetail(detail) };
  458. }
  459. function isLikelyMediaUrl(value) {
  460. if (!/^https?:\/\//i.test(String(value || ''))) return false;
  461. try {
  462. const parsed = new URL(String(value));
  463. const pathName = parsed.pathname.toLowerCase();
  464. const host = parsed.hostname.toLowerCase();
  465. if (/\.(wav|mp3|m4a|aac|flac|ogg|oga|opus|pcm|mp4|mov|m4v|webm|mkv)$/i.test(pathName)) return true;
  466. if (host.includes('douyinvod.com')) return true;
  467. if (host.includes('amemv.com') && pathName.includes('/aweme/v1/play')) return true;
  468. const mime = parsed.searchParams.get('mime_type') || '';
  469. return /audio|video_mp4|video/.test(mime);
  470. } catch {
  471. return false;
  472. }
  473. }
  474. function decodeMaybeBase64Url(value) {
  475. const text = clean(value);
  476. if (!text) return '';
  477. if (/^https?:\/\//i.test(text)) return text;
  478. if (!/^[A-Za-z0-9+/=_-]+$/.test(text) || text.length < 24) return '';
  479. try {
  480. const decoded = atobCompatible(text.replace(/-/g, '+').replace(/_/g, '/'));
  481. return /^https?:\/\//i.test(decoded) ? decoded : '';
  482. } catch {
  483. return '';
  484. }
  485. }
  486. function atobCompatible(value) {
  487. if (typeof atob === 'function') return atob(value);
  488. if (typeof Buffer !== 'undefined') return Buffer.from(value, 'base64').toString('utf8');
  489. return '';
  490. }
  491. function durationFromDetail(detail) {
  492. const raw = detail?.video?.duration || detail?.duration || detail?.video_duration || detail?.durationMs;
  493. const numeric = Number(raw || 0);
  494. if (!Number.isFinite(numeric) || numeric <= 0) return 0;
  495. return numeric > 10000 ? Math.round(numeric) : Math.round(numeric * 1000);
  496. }
  497. function extractDurationMs(value) {
  498. const found = findFirstNumber(value, ['duration', 'duration_ms', 'durationMs', 'video_duration']);
  499. if (!found) return 0;
  500. return found > 1000 ? Math.round(found) : Math.round(found * 1000);
  501. }
  502. function findFirstNumber(value, keys, depth = 0) {
  503. if (!value || depth > 5) return 0;
  504. if (Array.isArray(value)) {
  505. for (const item of value) {
  506. const hit = findFirstNumber(item, keys, depth + 1);
  507. if (hit) return hit;
  508. }
  509. return 0;
  510. }
  511. if (typeof value !== 'object') return 0;
  512. for (const [key, raw] of Object.entries(value)) {
  513. if (keys.includes(key)) {
  514. const number = Number(raw);
  515. if (Number.isFinite(number) && number > 0) return number;
  516. }
  517. const hit = findFirstNumber(raw, keys, depth + 1);
  518. if (hit) return hit;
  519. }
  520. return 0;
  521. }
  522. async function uploadGatewayAudio(mediaUrl, durationMs, token, context = {}) {
  523. let mediaResp;
  524. try {
  525. mediaResp = await fetchWithTimeout(mediaUrl, {
  526. method: 'GET',
  527. headers: {
  528. Accept: '*/*',
  529. 'User-Agent': 'Mozilla/5.0',
  530. Referer: 'https://www.douyin.com/',
  531. Origin: 'https://www.douyin.com',
  532. },
  533. }, 60000, '下载音频');
  534. } catch (error) {
  535. throw new Error(`音频下载网络失败:${formatFetchError(error)}`);
  536. }
  537. if (!mediaResp.ok) throw new Error(`音频下载失败 HTTP ${mediaResp.status}`);
  538. const contentType = mediaResp.headers?.get?.('content-type') || '';
  539. const arrayBuffer = await mediaResp.arrayBuffer();
  540. const mimeType = normalizeAudioMime(contentType, mediaUrl);
  541. const filename = `douyin-audio-${Date.now()}${extensionFromUrl(mediaUrl, mimeType)}`;
  542. const blob = new Blob([arrayBuffer], { type: mimeType });
  543. const form = new FormData();
  544. form.append('audio', blob, filename);
  545. form.append('durationMs', String(durationMs));
  546. form.append('roleType', readEnv('IFLYTEK_IST_ROLE_TYPE') || '1');
  547. form.append('roleNum', readEnv('IFLYTEK_IST_ROLE_NUM') || '0');
  548. if (readEnv('IFLYTEK_IST_LANGUAGE')) form.append('language', readEnv('IFLYTEK_IST_LANGUAGE'));
  549. if (readEnv('IFLYTEK_IST_PD')) form.append('pd', readEnv('IFLYTEK_IST_PD'));
  550. let resp;
  551. try {
  552. resp = await fetchWithTimeout(`${TRANSCRIPTION_GATEWAY}/upload`, {
  553. method: 'POST',
  554. headers: {
  555. Authorization: bearerAuth(token),
  556. Accept: 'application/json',
  557. },
  558. body: form,
  559. }, 300000, '转写网关上传');
  560. } catch (error) {
  561. const mediaSource = context.keyPath ? `,media=${context.sourceKind || 'media'}:${context.keyPath}` : '';
  562. throw new Error(`转写网关上传网络失败:${formatFetchError(error)}。gateway=${maskBaseUrl(TRANSCRIPTION_GATEWAY)},tokenSource=${transcriptTokenSource(token)},file=${filename},size=${arrayBuffer.byteLength}${mediaSource}`);
  563. }
  564. const text = await resp.text();
  565. const data = parseJson(text) || { rawText: text };
  566. if (!resp.ok || data.success === false) throw new Error(readGatewayError(data) || `转写上传失败 HTTP ${resp.status}`);
  567. const orderId = data.orderId || data.content?.orderId || data.data?.orderId || data.result?.orderId;
  568. if (!orderId) throw new Error('转写网关未返回 orderId');
  569. return { orderId, estimateTime: Number(data.estimateTime || data.content?.estimateTime || data.data?.estimateTime || 0) };
  570. }
  571. async function queryGateway(orderId, token) {
  572. let resp;
  573. try {
  574. resp = await fetchWithTimeout(`${TRANSCRIPTION_GATEWAY}/result`, {
  575. method: 'POST',
  576. headers: {
  577. Authorization: bearerAuth(token),
  578. Accept: 'application/json',
  579. 'Content-Type': 'application/json',
  580. },
  581. body: JSON.stringify({ orderId }),
  582. }, 120000, '转写网关查询');
  583. } catch (error) {
  584. throw new Error(`转写网关查询网络失败:${formatFetchError(error)}。gateway=${maskBaseUrl(TRANSCRIPTION_GATEWAY)},tokenSource=${transcriptTokenSource(token)}`);
  585. }
  586. const text = await resp.text();
  587. const data = parseJson(text) || { rawText: text };
  588. if (!resp.ok) throw new Error(readGatewayError(data) || `转写查询失败 HTTP ${resp.status}`);
  589. return data;
  590. }
  591. function gatewayValue(data, key) {
  592. return data?.[key] ?? data?.data?.[key] ?? data?.result?.[key] ?? data?.content?.[key];
  593. }
  594. function readGatewayError(data) {
  595. const detail = data?.detail;
  596. if (detail === 'Not Found') return '抖音数据接口地址未找到,请检查 DOUYIN_API_BASE_URL 是否配置为 https://server.fmode.cn/api/voc-social';
  597. const type = clean(data?.type || data?.error?.type);
  598. const raw = data?.mess || data?.message || data?.msg || data?.error?.message || data?.error || detail || data?.rawText || '';
  599. const message = typeof raw === 'string'
  600. ? raw
  601. : raw && typeof raw === 'object'
  602. ? JSON.stringify(raw)
  603. : String(raw || '');
  604. if (/unauthorized/i.test(type) || /unauthorized/i.test(message)) {
  605. return '转写网关鉴权失败:请确认当前登录用户的 VOC/APIG 权限、余额和 session token 是否有效。';
  606. }
  607. if (/company或用户信息不存在/.test(String(message))) {
  608. return '当前抖音数据网关 token 未绑定有效用户或公司,请在云函数配置 DOUYIN_API_TOKEN/VOC_TOKEN/VOC_SOCIAL_TOKEN,不能使用 TikHub token。';
  609. }
  610. return message;
  611. }
  612. function normalizeGatewaySegments(segments) {
  613. const arr = Array.isArray(segments) ? segments : [];
  614. return arr.map(segment => ({
  615. start: normalizeTime(segment.start ?? segment.begin ?? segment.bg),
  616. end: normalizeTime(segment.end ?? segment.ed),
  617. text: clean(segment.text || segment.onebest || segment.content),
  618. })).filter(segment => segment.text);
  619. }
  620. function normalizeTime(value) {
  621. const number = Number(value);
  622. if (!Number.isFinite(number)) return null;
  623. return number > 1000 ? number / 1000 : number;
  624. }
  625. function bearerAuth(token) {
  626. const value = clean(token);
  627. return /^Bearer\s+/i.test(value) ? value : `Bearer ${value}`;
  628. }
  629. async function fetchWithTimeout(url, init, timeoutMs, label) {
  630. const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
  631. const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
  632. try {
  633. return await fetch(url, {
  634. ...init,
  635. signal: controller ? controller.signal : init?.signal,
  636. });
  637. } catch (error) {
  638. if (error && error.name === 'AbortError') {
  639. throw new Error(`${label || '请求'}超时(${timeoutMs}ms)`);
  640. }
  641. throw error;
  642. } finally {
  643. if (timer) clearTimeout(timer);
  644. }
  645. }
  646. function normalizeAudioMime(contentType, url) {
  647. const value = clean(contentType).split(';')[0].toLowerCase();
  648. if (/^audio\//.test(value) || value === 'video/mp4') return value;
  649. const pathname = (() => {
  650. try { return new URL(url).pathname.toLowerCase(); } catch { return String(url || '').toLowerCase(); }
  651. })();
  652. if (pathname.endsWith('.mp3')) return 'audio/mpeg';
  653. if (pathname.endsWith('.wav')) return 'audio/wav';
  654. if (pathname.endsWith('.aac')) return 'audio/aac';
  655. if (pathname.endsWith('.m4a')) return 'audio/mp4';
  656. return 'audio/mp4';
  657. }
  658. function extensionFromUrl(url, mimeType) {
  659. try {
  660. const ext = new URL(url).pathname.match(/\.(mp3|m4a|aac|wav|mp4|mov|webm)$/i)?.[0];
  661. if (ext) return ext.toLowerCase();
  662. } catch {}
  663. const mime = clean(mimeType).toLowerCase();
  664. if (mime.includes('mpeg')) return '.mp3';
  665. if (mime.includes('wav')) return '.wav';
  666. if (mime.includes('aac')) return '.aac';
  667. return '.m4a';
  668. }
  669. function douyinTokenSource() {
  670. if (readEnv('DOUYIN_API_TOKEN')) return 'DOUYIN_API_TOKEN';
  671. if (readEnv('VOC_TOKEN')) return 'VOC_TOKEN';
  672. if (readEnv('TRANSCRIPTION_VOC_TOKEN')) return 'TRANSCRIPTION_VOC_TOKEN';
  673. if (readEnv('VOICE_TOKEN')) return 'VOICE_TOKEN';
  674. if (readEnv('OPENCLAW_VOC_TOKEN')) return 'OPENCLAW_VOC_TOKEN';
  675. if (readEnv('VOC_SOCIAL_TOKEN')) return 'VOC_SOCIAL_TOKEN';
  676. if (IS_TIKHUB_DIRECT && readEnv('TIKHUB_TOKEN')) return 'TIKHUB_TOKEN';
  677. if (!IS_TIKHUB_DIRECT && LOCAL_VOC_TOKEN_FALLBACK) return 'LOCAL_VOC_TOKEN_FALLBACK';
  678. return IS_TIKHUB_DIRECT ? 'TIKHUB_TOKEN_FALLBACK' : '';
  679. }
  680. function transcriptionGatewayToken(session) {
  681. return clean(session?.sessionToken) || clean(TRANSCRIPTION_GATEWAY_TOKEN);
  682. }
  683. function transcriptTokenSource(token) {
  684. if (token && clean(token).startsWith('r:')) return 'current_user_sessionToken';
  685. if (readEnv('TRANSCRIPTION_VOC_TOKEN')) return 'TRANSCRIPTION_VOC_TOKEN';
  686. if (readEnv('VOICE_TOKEN')) return 'VOICE_TOKEN';
  687. if (readEnv('OPENCLAW_VOC_TOKEN')) return 'OPENCLAW_VOC_TOKEN';
  688. if (readEnv('VOC_TOKEN')) return 'VOC_TOKEN';
  689. if (readEnv('VOC_SOCIAL_TOKEN')) return 'VOC_SOCIAL_TOKEN';
  690. if (clean(token)) return 'TRANSCRIPTION_GATEWAY_TOKEN';
  691. return '';
  692. }
  693. function parseJson(text) {
  694. try {
  695. return text ? JSON.parse(text) : null;
  696. } catch {
  697. return null;
  698. }
  699. }
  700. function formatFetchError(error) {
  701. const message = error && error.message ? error.message : String(error || 'fetch failed');
  702. const cause = error && error.cause ? `;cause=${error.cause.code || error.cause.message || error.cause}` : '';
  703. return `${message}${cause}`;
  704. }
  705. function maskBaseUrl(value) {
  706. return String(value || '').replace(/(token=)[^&]+/ig, '$1***');
  707. }
  708. function pickParam(request, ...names) {
  709. const sources = [request.params, request.body, request];
  710. for (const src of sources) {
  711. if (!src || typeof src !== 'object') continue;
  712. for (const name of names) {
  713. const value = src[name];
  714. if (value !== undefined && value !== null && value !== '') return value;
  715. }
  716. }
  717. return null;
  718. }
  719. function clean(value) {
  720. return String(value || '').trim();
  721. }
  722. function generateId() {
  723. const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  724. let s = '';
  725. for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
  726. return s;
  727. }
  728. function readEnv(name) {
  729. if (typeof process !== 'undefined' && process.env && process.env[name]) {
  730. return process.env[name];
  731. }
  732. return '';
  733. }
  734. if (typeof module !== 'undefined' && module.exports && readEnv('NODE_ENV') === 'test') {
  735. module.exports = {
  736. __test: {
  737. findAwemeDetail,
  738. selectMediaCandidate,
  739. decodeMaybeBase64Url,
  740. durationFromDetail,
  741. transcriptionGatewayToken,
  742. transcriptTokenSource,
  743. readGatewayError,
  744. },
  745. };
  746. }