Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
const form = document.getElementById("comment-form");
const statusText = document.getElementById("statusText");
const statusDetails = document.getElementById("statusDetails");
const submitBtn = document.getElementById("submitBtn");
const apiKeyPreview = document.getElementById("api-key-preview");

const setStatus = (message, details = [], tone = "idle") => {
statusText.textContent = message;
statusDetails.innerHTML = "";
details.forEach((detail) => {
const pill = document.createElement("span");
pill.textContent = detail;
statusDetails.appendChild(pill);
});

const dot = document.querySelector(".status-dot");
const colors = {
idle: "#94a3b8",
working: "#2563eb",
success: "#16a34a",
error: "#dc2626",
};
dot.style.background = colors[tone] || colors.idle;
};

const escapeCsv = (value) => {
if (value === null || value === undefined) {
return "";
}
const stringValue = String(value).replace(/\r?\n/g, " ");
if (stringValue.includes(",") || stringValue.includes('"')) {
return `"${stringValue.replace(/"/g, '""')}"`;
}
return stringValue;
};

const createCsv = (rows) => {
const header = [
"评论ID",
"用户昵称",
"评论内容",
"点赞数",
"发布时间",
"IP/城市",
"原始链接",
];
const lines = [header.join(",")];
rows.forEach((row) => {
lines.push(
[
escapeCsv(row.id),
escapeCsv(row.nickname),
escapeCsv(row.content),
escapeCsv(row.likes),
escapeCsv(row.createdAt),
escapeCsv(row.city),
escapeCsv(row.sourceUrl),
].join(",")
);
});
return lines.join("\n");
};

const downloadCsv = (csv, filename) => {
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};

const normalizeComments = (payload) => {
const list =
payload?.data?.comments ||
payload?.data?.items ||
payload?.comments ||
payload?.items ||
payload ||
[];

if (!Array.isArray(list)) {
return [];
}

return list.map((item) => ({
id: item.id || item.comment_id || item.cid || "",
nickname: item.nickname || item.user_name || item.author || "",
content: item.content || item.text || "",
likes: item.likes || item.like_count || item.digg_count || 0,
createdAt: item.created_at || item.time || item.publish_time || "",
city: item.city || item.ip_location || item.region || "",
sourceUrl: item.source_url || item.url || "",
}));
};

form.addEventListener("submit", async (event) => {
event.preventDefault();
submitBtn.disabled = true;

const apiKey = document.getElementById("apiKey").value.trim();
const apiEndpoint = document.getElementById("apiEndpoint").value.trim();
const videoUrl = document.getElementById("videoUrl").value.trim();
const limit = Number(document.getElementById("limit").value);
const order = document.getElementById("order").value;
const minLikes = Number(document.getElementById("minLikes").value || 0);
const keywords = document.getElementById("keywords").value.trim();
const requirements = document.getElementById("requirements").value.trim();

apiKeyPreview.textContent = apiKey ? apiKey : "未填写";

setStatus("正在提交 API 请求...", ["准备中"], "working");

try {
const response = await fetch(apiEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
url: videoUrl,
limit,
order,
min_likes: minLikes,
keywords: keywords ? keywords.split(/\s+/) : [],
requirements,
}),
});

if (!response.ok) {
throw new Error(`API 请求失败:${response.status}`);
}

const payload = await response.json();
let comments = normalizeComments(payload);

if (minLikes > 0) {
comments = comments.filter((comment) => Number(comment.likes) >= minLikes);
}

if (keywords) {
const keywordList = keywords.split(/\s+/).filter(Boolean);
comments = comments.filter((comment) =>
keywordList.every((keyword) => comment.content.includes(keyword))
);
}

if (order === "likes_desc") {
comments.sort((a, b) => Number(b.likes) - Number(a.likes));
} else if (order === "time_desc") {
comments.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
} else if (order === "time_asc") {
comments.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
Comment on lines +154 to +157

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Sort timestamps numerically for time-based order

The time-based sort uses String(...).localeCompare(...), which performs lexicographic ordering. If createdAt is a Unix timestamp or any non–zero-padded date string (common in APIs), lexicographic order mis-sorts values (e.g., "9" > "10"). This causes time_desc/time_asc to return incorrect results for those inputs; parsing to numbers or Date objects before comparing would avoid the misordering.

Useful? React with 👍 / 👎.

}

const limitedComments = comments.slice(0, limit);
const csv = createCsv(limitedComments);
downloadCsv(csv, "douyin-comments.csv");

setStatus(
`已成功导出 ${limitedComments.length} 条评论。`,
["已下载 CSV", `排序:${order}`, `最少点赞:${minLikes}`],
"success"
);
} catch (error) {
setStatus(
"提取失败,请检查 API 配置或稍后重试。",
[error.message || "未知错误"],
"error"
);
} finally {
submitBtn.disabled = false;
}
});
122 changes: 122 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DataMiner 抖音评论导出</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="app">
<header class="header">
<div>
<p class="eyebrow">DataMiner</p>
<h1>抖音视频评论导出</h1>
<p class="subtitle">
输入抖音视频链接、提取数量与要求,自动调用 API 拉取评论并导出 Excel(CSV)。
</p>
</div>
<div class="badge">
<span>API Key</span>
<p id="api-key-preview">sd_56d36aa0c0533600bb8a0ebde9c6c8cc</p>
</div>
</header>

<main class="card">
<form id="comment-form">
<div class="grid">
<label class="field">
<span>API Key</span>
<input
id="apiKey"
type="password"
value="sd_56d36aa0c0533600bb8a0ebde9c6c8cc"
placeholder="输入 DataMiner API Key"
required
/>
</label>
<label class="field">
<span>API Endpoint</span>
<input
id="apiEndpoint"
type="url"
value="https://api.dataminer.com/douyin/comments"
placeholder="https://api.dataminer.com/douyin/comments"
required
/>
</label>
</div>

<label class="field">
<span>抖音视频链接</span>
<input
id="videoUrl"
type="url"
placeholder="https://www.douyin.com/video/xxxxxxxx"
required
/>
</label>

<div class="grid">
<label class="field">
<span>提取数量</span>
<input id="limit" type="number" min="1" max="500" value="50" required />
</label>
<label class="field">
<span>排序要求</span>
<select id="order">
<option value="likes_desc">高赞优先</option>
<option value="time_desc">最新优先</option>
<option value="time_asc">最早优先</option>
</select>
</label>
</div>

<div class="grid">
<label class="field">
<span>最低点赞数</span>
<input id="minLikes" type="number" min="0" value="0" />
</label>
<label class="field">
<span>包含关键词(可选)</span>
<input id="keywords" type="text" placeholder="品牌名 / 话题 / 关键词" />
</label>
</div>

<label class="field">
<span>其他要求</span>
<textarea
id="requirements"
rows="3"
placeholder="例如:过滤表情、保留带话题评论、不要广告等"
></textarea>
</label>

<div class="actions">
<button id="submitBtn" type="submit">开始提取并导出</button>
<p class="hint">导出文件可直接用 Excel 打开。</p>
</div>
</form>

<section class="status" aria-live="polite">
<div class="status-row">
<span class="status-dot"></span>
<div>
<h2>提取进度</h2>
<p id="statusText">等待提交请求。</p>
</div>
</div>
<div class="status-details" id="statusDetails"></div>
</section>
</main>

<footer class="footer">
<p>
使用说明:如 API Endpoint 与 DataMiner 实际接口不同,请替换为控制台中的接口地址。
</p>
</footer>
</div>

<script src="app.js"></script>
</body>
</html>
Loading