diff --git a/app.js b/app.js new file mode 100644 index 0000000..a7f69a3 --- /dev/null +++ b/app.js @@ -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))); + } + + 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; + } +}); diff --git a/index.html b/index.html new file mode 100644 index 0000000..e975603 --- /dev/null +++ b/index.html @@ -0,0 +1,122 @@ + + + + + + DataMiner 抖音评论导出 + + + +
+
+
+

DataMiner

+

抖音视频评论导出

+

+ 输入抖音视频链接、提取数量与要求,自动调用 API 拉取评论并导出 Excel(CSV)。 +

+
+
+ API Key +

sd_56d36aa0c0533600bb8a0ebde9c6c8cc

+
+
+ +
+
+
+ + +
+ + + +
+ + +
+ +
+ + +
+ + + +
+ +

导出文件可直接用 Excel 打开。

+
+
+ +
+
+ +
+

提取进度

+

等待提交请求。

+
+
+
+
+
+ + +
+ + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..cd734f0 --- /dev/null +++ b/styles.css @@ -0,0 +1,210 @@ +:root { + color-scheme: light; + font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + background: #f5f7fb; + color: #0f172a; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 32px; +} + +.app { + max-width: 960px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 24px; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; +} + +.eyebrow { + margin: 0; + font-weight: 600; + color: #2563eb; +} + +h1 { + margin: 8px 0; + font-size: 32px; +} + +.subtitle { + margin: 0; + color: #475569; + max-width: 520px; +} + +.badge { + background: white; + border-radius: 16px; + padding: 16px 20px; + box-shadow: 0 10px 25px rgba(15, 23, 42, 0.08); + min-width: 260px; +} + +.badge span { + display: block; + color: #64748b; + font-size: 12px; +} + +.badge p { + margin: 8px 0 0; + font-weight: 600; +} + +.card { + background: white; + border-radius: 20px; + padding: 28px; + display: grid; + gap: 24px; + box-shadow: 0 15px 30px rgba(15, 23, 42, 0.08); +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; +} + +.field { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 14px; +} + +.field span { + font-weight: 600; + color: #1e293b; +} + +input, +select, +textarea { + width: 100%; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid #e2e8f0; + font-size: 14px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); +} + +.actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +button { + background: #2563eb; + color: white; + border: none; + border-radius: 12px; + padding: 12px 24px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +button:disabled { + cursor: not-allowed; + background: #94a3b8; +} + +button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 10px 20px rgba(37, 99, 235, 0.25); +} + +.hint { + margin: 0; + color: #64748b; +} + +.status { + border-radius: 16px; + border: 1px solid #e2e8f0; + padding: 20px; + background: #f8fafc; +} + +.status-row { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.status-dot { + width: 12px; + height: 12px; + border-radius: 50%; + margin-top: 6px; + background: #94a3b8; +} + +.status h2 { + margin: 0 0 6px; + font-size: 16px; +} + +.status p { + margin: 0; + color: #475569; +} + +.status-details { + margin-top: 16px; + display: grid; + gap: 8px; + font-size: 13px; + color: #1e293b; +} + +.status-details span { + display: inline-block; + background: #e2e8f0; + padding: 6px 10px; + border-radius: 999px; +} + +.footer { + font-size: 12px; + color: #64748b; + text-align: center; +} + +@media (max-width: 720px) { + body { + padding: 20px; + } + + .header { + flex-direction: column; + align-items: flex-start; + } +}