probe-dy-web-search.js 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. #!/usr/bin/env node
  2. /**
  3. * 直接用 douyin.com web 搜索(绕开 TikHub),从返回 HTML 里抽 sec_uid
  4. */
  5. const fs = require('fs');
  6. const path = require('path');
  7. const https = require('https');
  8. const zlib = require('zlib');
  9. const COOKIE = fs.readFileSync(path.resolve('data/douyin-cookie.txt'), 'utf-8').trim();
  10. function fetchHtml(url, cookie) {
  11. const u = new URL(url);
  12. return new Promise((resolve) => {
  13. const opts = {
  14. hostname: u.hostname,
  15. path: u.pathname + u.search,
  16. method: 'GET',
  17. headers: {
  18. 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0',
  19. 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
  20. 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
  21. 'Accept-Encoding': 'gzip, deflate, br',
  22. 'Referer': 'https://www.douyin.com/',
  23. Cookie: cookie,
  24. },
  25. };
  26. const req = https.request(opts, (res) => {
  27. const chunks = [];
  28. res.on('data', (c) => chunks.push(c));
  29. res.on('end', () => {
  30. let buf = Buffer.concat(chunks);
  31. const enc = res.headers['content-encoding'];
  32. try {
  33. if (enc === 'gzip') buf = zlib.gunzipSync(buf);
  34. else if (enc === 'deflate') buf = zlib.inflateSync(buf);
  35. else if (enc === 'br') buf = zlib.brotliDecompressSync(buf);
  36. } catch (e) {
  37. // ignore
  38. }
  39. resolve({ status: res.statusCode, body: buf.toString('utf-8'), headers: res.headers });
  40. });
  41. });
  42. req.on('error', (e) => resolve({ status: 0, error: e.message }));
  43. req.setTimeout(20000, () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); });
  44. req.end();
  45. });
  46. }
  47. function extractSecUids(html) {
  48. const results = [];
  49. const seen = new Set();
  50. // 抖音 HTML 里 sec_uid 通常出现在 "sec_uid":"MS4wLjABAAAA..." 或 url: /user/MS4wLjABAAAA...
  51. const regexes = [
  52. /"sec_uid"\s*:\s*"(MS4wLjABAAAA[A-Za-z0-9_\-]+)"/g,
  53. /\/user\/(MS4wLjABAAAA[A-Za-z0-9_\-]+)/g,
  54. ];
  55. for (const re of regexes) {
  56. let m;
  57. while ((m = re.exec(html)) !== null) {
  58. if (!seen.has(m[1])) {
  59. seen.add(m[1]);
  60. results.push(m[1]);
  61. }
  62. }
  63. }
  64. return results;
  65. }
  66. function extractNicknameForSecUid(html, secUid) {
  67. // 寻找 sec_uid 附近的 nickname
  68. const re = new RegExp('"sec_uid"\\s*:\\s*"' + secUid + '"[\\s\\S]{0,800}?"nickname"\\s*:\\s*"([^"]{1,80})"');
  69. const m = html.match(re);
  70. if (m) return m[1];
  71. // 反向
  72. const re2 = new RegExp('"nickname"\\s*:\\s*"([^"]{1,80})"[\\s\\S]{0,800}?"sec_uid"\\s*:\\s*"' + secUid + '"');
  73. const m2 = html.match(re2);
  74. return m2 ? m2[1] : '';
  75. }
  76. function extractFansForSecUid(html, secUid) {
  77. const re = new RegExp('"sec_uid"\\s*:\\s*"' + secUid + '"[\\s\\S]{0,1500}?"follower_count"\\s*:\\s*(\\d+)');
  78. const m = html.match(re);
  79. return m ? +m[1] : 0;
  80. }
  81. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  82. const candidates = [
  83. { product: 'liver', role: '品牌竞品', keyword: '葵花药业' },
  84. { product: 'liver', role: '品牌竞品', keyword: '葵花护肝片' },
  85. { product: 'liver', role: '垂类博主', keyword: '肝病科' },
  86. { product: 'probiotic', role: '品牌竞品', keyword: '妈咪爱' },
  87. { product: 'probiotic', role: '品牌竞品', keyword: '亿活益生菌' },
  88. { product: 'probiotic', role: '品牌竞品', keyword: '合生元' },
  89. { product: 'probiotic', role: '垂类博主', keyword: '儿科医生' },
  90. { product: 'monkey', role: '品牌竞品', keyword: '康恩贝' },
  91. { product: 'monkey', role: '品牌竞品', keyword: '太阳神' },
  92. { product: 'monkey', role: '品牌竞品', keyword: '三九胃泰' },
  93. { product: 'monkey', role: '垂类博主', keyword: '养胃' },
  94. ];
  95. (async () => {
  96. const out = [];
  97. for (const c of candidates) {
  98. await sleep(1500);
  99. const url = `https://www.douyin.com/search/${encodeURIComponent(c.keyword)}?type=user`;
  100. console.log(`\n[${c.product} · ${c.role}] ${c.keyword}`);
  101. const r = await fetchHtml(url, COOKIE);
  102. if (r.error) {
  103. console.log(' ❌ fetch error:', r.error);
  104. out.push({ ...c, _error: r.error });
  105. continue;
  106. }
  107. console.log(' status:', r.status, '| body size:', r.body.length);
  108. // 保存首个样本用于调试
  109. if (out.length === 0) {
  110. fs.writeFileSync('data/dy-web-search-sample.html', r.body.slice(0, 200000), 'utf-8');
  111. }
  112. const secUids = extractSecUids(r.body);
  113. console.log(' sec_uids found:', secUids.length);
  114. if (secUids.length === 0) {
  115. const dataMatch = r.body.match(/<script\s+id="RENDER_DATA"[^>]*>([\s\S]{0,300})/);
  116. console.log(' snippet:', dataMatch ? dataMatch[1].slice(0, 200) : r.body.slice(0, 300));
  117. out.push({ ...c, hits: [] });
  118. continue;
  119. }
  120. const top = secUids.slice(0, 5).map((sec_uid) => ({
  121. sec_uid,
  122. nickname: extractNicknameForSecUid(r.body, sec_uid),
  123. follower_count: extractFansForSecUid(r.body, sec_uid),
  124. share_url: 'https://www.douyin.com/user/' + sec_uid,
  125. }));
  126. top.forEach((t, i) => console.log(` #${i + 1} ${t.nickname || '(no name)'} · 粉丝 ${t.follower_count.toLocaleString()} · ${t.sec_uid.slice(0, 20)}...`));
  127. out.push({ ...c, hits: top });
  128. }
  129. fs.writeFileSync('data/dy-web-search-out.json', JSON.stringify(out, null, 2), 'utf-8');
  130. console.log('\n✓ Wrote data/dy-web-search-out.json');
  131. })();