| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281 |
- #!/usr/bin/env node
- /**
- * TikTok 采集模块(TikHub)
- *
- * TikTok 是 Amazon 的内容端对标 —— 用英文关键词驱动:
- * - milk thistle / liver support → 肝纯片
- * - kids probiotics → 儿童乳酸菌
- * - lions mane mushroom → 猴菇饮
- *
- * 流程(每产品):
- * 1) 对每个 keyword_en 走 fetch_general_search_result,累计视频
- * 2) 去重后取 Top 8 高赞视频,每个抓前 20 条评论
- * 3) 合并所有视频的 author,按 follower_count 取 Top 5 作者,抓 profile + Top 10 作品
- * 4) 合并用户搜索 (/web/fetch_search_user) 拿到品类头部账号
- *
- * 不需要 cookie(TikTok 海外版)
- */
- const fs = require('fs');
- const path = require('path');
- const os = require('os');
- const https = require('https');
- const TIKHUB_TOKEN = JSON.parse(
- fs.readFileSync(
- path.join(os.homedir(), '.openclaw', 'skills', 'xiaohongshu-search-notes', 'api-config.json'),
- 'utf-8'
- )
- ).endpoint.headers.Authorization.replace(/^Bearer\s+/, '');
- function get(apiPath, params = {}) {
- const qs = Object.entries(params)
- .filter(([, v]) => v !== undefined && v !== null && v !== '')
- .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
- .join('&');
- const fullPath = qs ? `${apiPath}?${qs}` : apiPath;
- return new Promise((resolve) => {
- const opts = {
- hostname: 'api.tikhub.io',
- path: fullPath,
- method: 'GET',
- headers: { Authorization: `Bearer ${TIKHUB_TOKEN}`, Accept: 'application/json' },
- };
- const req = https.request(opts, (res) => {
- const chunks = [];
- res.on('data', (c) => chunks.push(c));
- res.on('end', () => {
- const body = Buffer.concat(chunks).toString('utf-8');
- try { resolve({ status: res.statusCode, json: JSON.parse(body) }); }
- catch (e) { resolve({ status: res.statusCode, json: { _raw: body.slice(0, 200), _parseError: e.message } }); }
- });
- });
- req.on('error', (e) => resolve({ status: 0, json: { _error: e.message } }));
- req.setTimeout(60000, () => { req.destroy(); resolve({ status: 0, json: { _error: 'timeout' } }); });
- req.end();
- });
- }
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
- // ============================================================
- // 基础工具:解析搜索结果为扁平的视频数组
- // ============================================================
- function parseSearchVideos(json) {
- const d = json?.data;
- if (!d) return [];
- // TikTok search 返回 { data: { data: [...], cursor, ... } }
- let list = Array.isArray(d) ? d : (Array.isArray(d.data) ? d.data : (Array.isArray(d.aweme_list) ? d.aweme_list : []));
- return list
- .map((item) => item?.aweme_info || item)
- .filter((v) => v && v.aweme_id);
- }
- function slimVideo(v) {
- return {
- aweme_id: v.aweme_id,
- desc: v.desc,
- create_time: v.create_time,
- duration: v.duration,
- statistics: v.statistics ? {
- digg_count: v.statistics.digg_count || 0,
- comment_count: v.statistics.comment_count || 0,
- play_count: v.statistics.play_count || 0,
- share_count: v.statistics.share_count || 0,
- collect_count: v.statistics.collect_count || 0,
- download_count: v.statistics.download_count || 0,
- } : null,
- author: v.author ? {
- sec_uid: v.author.sec_uid,
- uid: v.author.uid,
- nickname: v.author.nickname,
- unique_id: v.author.unique_id,
- signature: v.author.signature,
- follower_count: v.author.follower_count,
- total_favorited: v.author.total_favorited,
- aweme_count: v.author.aweme_count,
- avatar: v.author.avatar_thumb?.url_list?.[0] || v.author.avatar_larger?.url_list?.[0],
- verification_type: v.author.verification_type,
- custom_verify: v.author.custom_verify,
- } : null,
- cover: v.video?.cover?.url_list?.[0] || v.video?.origin_cover?.url_list?.[0],
- hashtags: (v.text_extra || []).map((t) => t.hashtag_name).filter(Boolean),
- region: v.region,
- };
- }
- // ============================================================
- // 主入口:单产品采集
- // ============================================================
- async function collectTikTokForProduct(product) {
- console.log('');
- console.log(` [tt] 🎵 TikTok for ${product.name}`);
- const keywords = (product.keywords_en || []).slice(0, 4);
- if (!keywords.length) {
- return { _error: 'no keywords_en provided' };
- }
- const result = {
- product: product.name,
- keywords_used: keywords,
- by_keyword: {},
- merged_videos: [],
- top_authors: [],
- author_profiles: {},
- author_posts: {},
- top_comments_by_video: {},
- category_users: {},
- collected_at: new Date().toISOString(),
- };
- // === 1) 对每个关键词做 2 轮 search(拿 ~20 条) ===
- const allVideos = [];
- const seenAwemeIds = new Set();
- for (const kw of keywords) {
- result.by_keyword[kw] = { videos: [], pages: 0 };
- let cursor = 0;
- for (let page = 0; page < 2; page++) {
- console.log(` [tt] 🔍 search "${kw}" page=${page + 1} cursor=${cursor}`);
- const r = await get('/api/v1/tiktok/app/v3/fetch_general_search_result', {
- keyword: kw, cursor, count: 10,
- });
- await sleep(1000);
- const videos = parseSearchVideos(r.json);
- console.log(` → ${videos.length} videos`);
- videos.forEach((v) => {
- if (!seenAwemeIds.has(v.aweme_id)) {
- seenAwemeIds.add(v.aweme_id);
- const slim = slimVideo(v);
- slim._from_keyword = kw;
- allVideos.push(slim);
- result.by_keyword[kw].videos.push(slim);
- }
- });
- result.by_keyword[kw].pages = page + 1;
- cursor = r.json?.data?.cursor || r.json?.data?.next_cursor || 0;
- if (!r.json?.data?.has_more || videos.length === 0) break;
- }
- }
- result.merged_videos = allVideos;
- console.log(` [tt] ✓ merged ${allVideos.length} unique videos (keywords=${keywords.length})`);
- if (!allVideos.length) {
- console.log(` [tt] ⚠ 无视频数据,返回空结果`);
- return result;
- }
- // === 2) 按点赞取 Top 8 视频,每个抓前 20 评论 ===
- const topVideos = [...allVideos]
- .sort((a, b) => (b.statistics?.digg_count || 0) - (a.statistics?.digg_count || 0))
- .slice(0, 8);
- for (const v of topVideos) {
- await sleep(900);
- console.log(` [tt] 💬 comments ${v.aweme_id} · ${(v.desc || '').slice(0, 40)}`);
- const rC = await get('/api/v1/tiktok/app/v3/fetch_video_comments', {
- aweme_id: v.aweme_id, cursor: 0, count: 20,
- });
- const raw = rC.json?.data?.comments || [];
- const comments = raw.map((c) => ({
- cid: c.cid,
- text: c.text,
- digg_count: c.digg_count || 0,
- create_time: c.create_time,
- reply_comment_total: c.reply_comment_total || 0,
- user: c.user ? {
- nickname: c.user.nickname,
- sec_uid: c.user.sec_uid,
- unique_id: c.user.unique_id,
- follower_count: c.user.follower_count,
- region: c.user.region,
- } : null,
- }));
- result.top_comments_by_video[v.aweme_id] = comments;
- console.log(` → ${comments.length} comments`);
- }
- // === 3) 合并作者 → Top 5 按粉丝,各抓 profile + Top 10 posts ===
- const authorMap = new Map();
- allVideos.forEach((v) => {
- if (v.author?.sec_uid && !authorMap.has(v.author.sec_uid)) {
- authorMap.set(v.author.sec_uid, v.author);
- }
- });
- const topAuthors = [...authorMap.values()]
- .sort((a, b) => (b.follower_count || 0) - (a.follower_count || 0))
- .slice(0, 5);
- result.top_authors = topAuthors.map((a) => ({
- sec_uid: a.sec_uid,
- nickname: a.nickname,
- unique_id: a.unique_id,
- follower_count: a.follower_count,
- total_favorited: a.total_favorited,
- aweme_count: a.aweme_count,
- custom_verify: a.custom_verify,
- avatar: a.avatar,
- }));
- for (const a of topAuthors) {
- await sleep(900);
- console.log(` [tt] 👤 profile @${a.unique_id || a.nickname} (${(a.follower_count || 0).toLocaleString()}粉)`);
- const rP = await get('/api/v1/tiktok/app/v3/handler_user_profile', {
- sec_user_id: a.sec_uid,
- });
- const u = rP.json?.data?.user;
- if (u) {
- result.author_profiles[a.sec_uid] = {
- nickname: u.nickname,
- unique_id: u.unique_id,
- sec_uid: u.sec_uid,
- signature: u.signature,
- follower_count: u.follower_count,
- following_count: u.following_count,
- total_favorited: u.total_favorited,
- aweme_count: u.aweme_count,
- custom_verify: u.custom_verify,
- verification_type: u.verification_type,
- region: u.region,
- avatar: u.avatar_larger?.url_list?.[0] || u.avatar_thumb?.url_list?.[0],
- };
- }
- await sleep(700);
- console.log(` [tt] 📹 posts @${a.unique_id || a.nickname}`);
- const rPost = await get('/api/v1/tiktok/app/v3/fetch_user_post_videos', {
- sec_user_id: a.sec_uid, max_cursor: 0, count: 10,
- });
- const awemeList = rPost.json?.data?.aweme_list || [];
- result.author_posts[a.sec_uid] = awemeList.map(slimVideo);
- console.log(` → ${result.author_posts[a.sec_uid].length} posts`);
- }
- // === 4) 品类用户搜索(可选,拿头部品牌/垂类号)===
- await sleep(1000);
- const primaryKw = keywords[0];
- console.log(` [tt] 🔎 user search "${primaryKw}"`);
- const rUser = await get('/api/v1/tiktok/web/fetch_search_user', {
- keyword: primaryKw, cursor: 0, count: 10,
- });
- const userList = rUser.json?.data;
- const userArr = Array.isArray(userList) ? userList : (Array.isArray(userList?.data) ? userList.data : (Array.isArray(userList?.user_list) ? userList.user_list : []));
- if (userArr.length) {
- result.category_users[primaryKw] = userArr.slice(0, 10).map((u) => {
- const ui = u.user_info || u;
- return {
- sec_uid: ui.sec_uid,
- unique_id: ui.unique_id,
- nickname: ui.nickname,
- follower_count: ui.follower_count,
- total_favorited: ui.total_favorited,
- aweme_count: ui.aweme_count,
- signature: ui.signature,
- custom_verify: ui.custom_verify,
- avatar: ui.avatar_thumb?.url_list?.[0] || ui.avatar_larger?.url_list?.[0],
- };
- });
- console.log(` → ${result.category_users[primaryKw].length} category users`);
- } else {
- console.log(` → 0 users (empty or error)`);
- }
- return result;
- }
- module.exports = { collectTikTokForProduct };
|