This commit is contained in:
fengchuanhn@gmail.com
2026-05-18 09:45:44 +08:00
parent c559cb1581
commit 35944cee39
4 changed files with 425 additions and 0 deletions

View File

@@ -0,0 +1,216 @@
"use strict";
/**
* 抖音用户主页采集(对齐 Electron ipAgent:analyzeDouyinAccount
*/
const https = require("https");
const { DouyinBrowserManager } = require("./douyin_browser_manager.js");
const PROFILE_URL_RE =
/douyin\.com\/user|iesdouyin\.com\/share\/user|v\.douyin\.com/i;
const SCRAPE_SCRIPT = `
(() => {
try {
let accountName = "";
const nicknameEl = document.querySelector(
'[class*="nickname"], [class*="account"], h1'
);
if (nicknameEl) accountName = nicknameEl.textContent?.trim() || "";
if (!accountName) {
const metaEl = document.querySelector('meta[property="og:title"]');
if (metaEl) accountName = metaEl.getAttribute("content") || "";
}
if (!accountName) {
const allHeaders = document.querySelectorAll(
'h1, h2, .dy-name, [data-testid*="name"], [class*="userName"]'
);
for (const el of allHeaders) {
const text = el.textContent?.trim();
if (text && text.length > 2 && text.length < 30) {
accountName = text;
break;
}
}
}
if (!accountName) {
const titleText = document.title || "";
const match = titleText.match(/(.+?)(?:的抖音|抖音号|-)/);
accountName = match ? match[1].trim() : titleText.split("-")[0]?.trim() || "";
}
const videoTitles = [];
const seenTexts = new Set();
const excludeWords = [
"抖音号", "IP属地", "获赞", "粉丝", "关注", "作品", "点赞", "评论",
"分享", "收藏", "私信", "粉丝团", "加关注", "最新作品", "合作推荐", "喜欢作者",
];
const shouldExclude = (text) => {
if (!text || text.length < 5 || text.length > 300) return true;
return excludeWords.some((word) => text.includes(word));
};
const allElements = document.querySelectorAll(
'p, span[class*="desc"], div[class*="title"]'
);
allElements.forEach((el) => {
if (videoTitles.length >= 6) return;
const text = el.textContent?.trim();
if (
text &&
text.length > 10 &&
text.length < 500 &&
!shouldExclude(text) &&
!seenTexts.has(text)
) {
videoTitles.push(text);
seenTexts.add(text);
}
});
if (videoTitles.length < 3) {
const titleSelectors = [
'[class*="title"]',
'[class*="desc"]',
'[class*="caption"]',
'[data-testid*="title"]',
'[class*="feed-item"]',
'[class*="video-item"]',
".dy-video-title",
".aweme-detail",
'[class*="aweme"]',
];
for (const selector of titleSelectors) {
if (videoTitles.length >= 6) break;
document.querySelectorAll(selector).forEach((el) => {
if (videoTitles.length >= 6) return;
const text = el.textContent?.trim();
if (text && !shouldExclude(text) && text.length > 5 && !seenTexts.has(text)) {
videoTitles.push(text);
seenTexts.add(text);
}
});
}
}
const cleanTitles = videoTitles
.map((title) => title.replace(/^\\d+\\s+/, "").trim())
.filter((t) => t && t.length > 5);
return {
accountName: accountName || "未知账号",
videoTitles: Array.from(new Set(cleanTitles)).slice(0, 6),
pageTitle: document.title,
pageUrl: window.location.href,
};
} catch (err) {
return {
accountName: document.title?.split("-")[0] || "错误",
videoTitles: [],
pageTitle: document.title,
pageUrl: window.location.href,
error: err.message,
};
}
})()
`;
function emitProgress(msg) {
try {
globalThis.__native?.emitProgress?.(msg);
} catch (_) {
/* ignore */
}
}
function resolveShortUrl(shortUrl) {
return new Promise((resolve, reject) => {
let u;
try {
u = new URL(shortUrl);
} catch (e) {
reject(e);
return;
}
const opts = {
hostname: u.hostname,
path: u.pathname + u.search,
method: "HEAD",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
},
timeout: 30000,
};
const req = https.request(opts, (res) => {
if (
res.statusCode &&
res.statusCode >= 300 &&
res.statusCode < 400 &&
res.headers.location
) {
resolve(res.headers.location);
} else {
resolve(shortUrl);
}
});
req.on("error", reject);
req.on("timeout", () => {
req.destroy();
reject(new Error("短链接解析超时"));
});
req.end();
});
}
async function normalizeProfileUrl(input) {
let url = String(input || "").trim();
if (!url) throw new Error("请输入账号主页或分享链接");
const urlMatch = url.match(/https?:\/\/[^\s\u4e00-\u9fa5"'<>]+/i);
if (urlMatch) url = urlMatch[0];
else if (!url.startsWith("http")) url = `https://${url}`;
if (/v\.douyin\.com|iesdouyin\.com/i.test(url) && !/\/user\//i.test(url)) {
emitProgress("正在解析分享链接...");
url = await resolveShortUrl(url);
}
if (!PROFILE_URL_RE.test(url)) {
throw new Error("请输入抖音账号主页或用户分享链接(支持 v.douyin.com 短链)");
}
return url;
}
async function scrapeDouyinProfile(profileUrl) {
const manager = DouyinBrowserManager.getInstance();
emitProgress("正在打开浏览器并采集数据,请稍候...");
await manager.initBrowser();
const page = await manager.newPage();
try {
await page.goto(profileUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
emitProgress("页面已加载,正在提取账号与视频标题...");
await new Promise((r) => setTimeout(r, 5000));
const data = await page.evaluate(SCRAPE_SCRIPT);
if (data?.error) {
throw new Error(data.error);
}
return data;
} finally {
try {
await page.close();
} catch (_) {
/* ignore */
}
}
}
module.exports = {
normalizeProfileUrl,
scrapeDouyinProfile,
};