claude-codex-wechat 0.1.58 → 0.1.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp/mediaServer.js
CHANGED
|
@@ -158,7 +158,7 @@ function findDouyinScript() {
|
|
|
158
158
|
function runScript(args) {
|
|
159
159
|
const script = findDouyinScript();
|
|
160
160
|
return new Promise((resolve2, reject) => {
|
|
161
|
-
execFile(nodeExecutable(), [script, ...args], { timeout:
|
|
161
|
+
execFile(nodeExecutable(), [script, ...args], { timeout: 18e4 }, (err, stdout, stderr) => {
|
|
162
162
|
if (err) reject(new Error(stderr || err.message));
|
|
163
163
|
else resolve2({ stdout, stderr });
|
|
164
164
|
});
|
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* 抖音无水印视频下载脚本(自包含,无下载器依赖)。
|
|
5
5
|
*
|
|
6
6
|
* 用法:
|
|
7
7
|
* node douyin-download.mjs "<抖音链接>"
|
|
8
8
|
* node douyin-download.mjs "<抖音链接>" --output ./videos/
|
|
9
9
|
* node douyin-download.mjs "<分享文案含链接>"
|
|
10
10
|
*
|
|
11
|
-
* 要求:Node.js
|
|
11
|
+
* 要求:Node.js 20+。新版抖音页面回退路径需要本机安装 Chrome/Chromium。
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { execFile } from "node:child_process";
|
|
15
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
15
17
|
import { resolve } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { promisify } from "node:util";
|
|
20
|
+
|
|
21
|
+
const execFileAsync = promisify(execFile);
|
|
16
22
|
|
|
17
23
|
// ─── 常量 ───────────────────────────────────────────────────────────────────
|
|
18
24
|
|
|
@@ -20,34 +26,38 @@ const MOBILE_UA =
|
|
|
20
26
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1";
|
|
21
27
|
const DESKTOP_UA =
|
|
22
28
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
29
|
+
const PLAYER_LOAD_TIMEOUT_MS = 35_000;
|
|
30
|
+
const PLAYER_VIRTUAL_TIME_MS = 15_000;
|
|
31
|
+
const PLAYER_ATTEMPTS = 2;
|
|
32
|
+
const MAX_DOM_BYTES = 2 * 1024 * 1024;
|
|
33
|
+
const ALLOWED_VIDEO_HOST_SUFFIXES = [".douyinvod.com", ".douyin.com", ".snssdk.com"];
|
|
23
34
|
|
|
24
35
|
// ─── 带重试/超时的 fetch ─────────────────────────────────────────────────────
|
|
25
36
|
|
|
26
|
-
const sleep = (ms) => new Promise((
|
|
37
|
+
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
27
38
|
|
|
28
39
|
/**
|
|
29
40
|
* fetch 包一层重试与超时,抵御瞬时网络抖动("fetch failed" 之类的底层网络错误)。
|
|
30
41
|
* 仅对网络层错误和 5xx/429 重试;4xx(除 429)视为确定性失败,不重试。
|
|
31
42
|
*/
|
|
32
43
|
async function fetchWithRetry(url, options = {}, { retries = 3, timeoutMs = 20_000 } = {}) {
|
|
33
|
-
let
|
|
44
|
+
let lastError;
|
|
34
45
|
for (let attempt = 1; attempt <= retries; attempt += 1) {
|
|
35
46
|
const controller = new AbortController();
|
|
36
47
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
37
48
|
try {
|
|
38
|
-
const
|
|
49
|
+
const response = await fetch(url, { ...options, signal: controller.signal });
|
|
39
50
|
clearTimeout(timer);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
lastErr = new Error(`HTTP ${resp.status}`);
|
|
51
|
+
if ((response.status >= 500 || response.status === 429) && attempt < retries) {
|
|
52
|
+
lastError = new Error(`HTTP ${response.status}`);
|
|
43
53
|
await sleep(attempt * 800);
|
|
44
54
|
continue;
|
|
45
55
|
}
|
|
46
|
-
return
|
|
47
|
-
} catch (
|
|
56
|
+
return response;
|
|
57
|
+
} catch (error) {
|
|
48
58
|
clearTimeout(timer);
|
|
49
|
-
|
|
50
|
-
const reason =
|
|
59
|
+
lastError = error;
|
|
60
|
+
const reason = error?.name === "AbortError" ? `请求超时(>${timeoutMs}ms)` : error?.message || String(error);
|
|
51
61
|
if (attempt < retries) {
|
|
52
62
|
await sleep(attempt * 800);
|
|
53
63
|
continue;
|
|
@@ -55,26 +65,25 @@ async function fetchWithRetry(url, options = {}, { retries = 3, timeoutMs = 20_0
|
|
|
55
65
|
throw new Error(`网络请求失败(已重试${retries}次):${reason}`);
|
|
56
66
|
}
|
|
57
67
|
}
|
|
58
|
-
throw
|
|
68
|
+
throw lastError ?? new Error("网络请求失败");
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
// ─── 解析逻辑 ────────────────────────────────────────────────────────────────
|
|
62
72
|
|
|
63
|
-
/** 从任意文本里提取第一个抖音 URL */
|
|
64
|
-
function extractDouyinUrl(text) {
|
|
65
|
-
const
|
|
66
|
-
/https?:\/\/(?:v\.douyin\.com|www\.douyin\.com|www\.iesdouyin\.com|iesdouyin\.com)\/[^\s"'))】]+/i
|
|
73
|
+
/** 从任意文本里提取第一个抖音 URL。 */
|
|
74
|
+
export function extractDouyinUrl(text) {
|
|
75
|
+
const matched = text.match(
|
|
76
|
+
/https?:\/\/(?:v\.douyin\.com|www\.douyin\.com|www\.iesdouyin\.com|iesdouyin\.com)\/[^\s"'))】]+/i,
|
|
67
77
|
);
|
|
68
|
-
return
|
|
78
|
+
return matched ? matched[0] : null;
|
|
69
79
|
}
|
|
70
80
|
|
|
71
|
-
/** 把任意输入解析成 aweme_id */
|
|
72
|
-
async function resolveAwemeId(input) {
|
|
81
|
+
/** 把任意输入解析成 aweme_id。 */
|
|
82
|
+
export async function resolveAwemeId(input) {
|
|
73
83
|
const trimmed = input.trim();
|
|
74
84
|
if (/^\d{15,}$/.test(trimmed)) return trimmed;
|
|
75
85
|
|
|
76
86
|
const url = extractDouyinUrl(trimmed) ?? trimmed;
|
|
77
|
-
|
|
78
87
|
const direct = url.match(/(?:share\/)?video\/(\d{15,})/);
|
|
79
88
|
if (direct) return direct[1];
|
|
80
89
|
|
|
@@ -82,96 +91,194 @@ async function resolveAwemeId(input) {
|
|
|
82
91
|
throw new Error(`无法识别的输入(既不是抖音链接,也不是 aweme_id):${trimmed}`);
|
|
83
92
|
}
|
|
84
93
|
|
|
85
|
-
const
|
|
94
|
+
const response = await fetchWithRetry(url, {
|
|
86
95
|
method: "GET",
|
|
87
96
|
headers: { "User-Agent": MOBILE_UA },
|
|
88
97
|
redirect: "follow",
|
|
89
98
|
});
|
|
90
|
-
const
|
|
91
|
-
if (!
|
|
92
|
-
return
|
|
99
|
+
const matched = response.url.match(/(\d{15,})/);
|
|
100
|
+
if (!matched) throw new Error(`短链重定向后未找到 aweme_id,最终地址:${response.url}`);
|
|
101
|
+
return matched[1];
|
|
93
102
|
}
|
|
94
103
|
|
|
95
|
-
/**
|
|
96
|
-
async function resolveVideoId(awemeId) {
|
|
97
|
-
const shareUrl = `https://www.iesdouyin.com/share/video/${awemeId}/`;
|
|
98
|
-
const resp = await fetchWithRetry(shareUrl, { headers: { "User-Agent": MOBILE_UA } });
|
|
99
|
-
if (!resp.ok) throw new Error(`分享页请求失败:HTTP ${resp.status}(aweme_id=${awemeId})`);
|
|
100
|
-
const html = await resp.text();
|
|
101
|
-
const m =
|
|
102
|
-
html.match(/video_id=([a-z0-9]+)/i) ??
|
|
103
|
-
html.match(/"video_id"\s*:\s*"([a-z0-9]+)"/i) ??
|
|
104
|
-
html.match(/playwm\/\?[^"'\\]*?video_id=([a-z0-9]+)/i);
|
|
105
|
-
if (!m) throw new Error(`分享页里未找到 video_id(aweme_id=${awemeId})`);
|
|
106
|
-
return m[1];
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** 拼无水印播放入口(play 而非 playwm) */
|
|
104
|
+
/** 拼无水印播放入口(play 而非 playwm)。 */
|
|
110
105
|
function buildPlayUrlNoWatermark(videoId, ratio = "720p") {
|
|
111
106
|
return `https://aweme.snssdk.com/aweme/v1/play/?ratio=${ratio}&video_id=${videoId}`;
|
|
112
107
|
}
|
|
113
108
|
|
|
109
|
+
/** 查找用户机器上已有的 Chrome/Chromium,不下载浏览器。 */
|
|
110
|
+
export function findChromeExecutable({ platform = process.platform, env = process.env, exists = existsSync } = {}) {
|
|
111
|
+
const explicit = [env.DOUYIN_CHROME_EXECUTABLE, env.CHROME_PATH, env.GOOGLE_CHROME_BIN].filter(Boolean);
|
|
112
|
+
const home = env.HOME || env.USERPROFILE || homedir();
|
|
113
|
+
const candidates = [...explicit];
|
|
114
|
+
|
|
115
|
+
if (platform === "darwin") {
|
|
116
|
+
candidates.push(
|
|
117
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
118
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
119
|
+
resolve(home, "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
|
|
120
|
+
);
|
|
121
|
+
} else if (platform === "win32") {
|
|
122
|
+
for (const root of [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA].filter(Boolean)) {
|
|
123
|
+
candidates.push(resolve(root, "Google/Chrome/Application/chrome.exe"), resolve(root, "Chromium/Application/chrome.exe"));
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
candidates.push(
|
|
127
|
+
"/usr/bin/google-chrome-stable",
|
|
128
|
+
"/usr/bin/google-chrome",
|
|
129
|
+
"/usr/bin/chromium",
|
|
130
|
+
"/usr/bin/chromium-browser",
|
|
131
|
+
"/snap/bin/chromium",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return candidates.find((candidate) => exists(candidate)) ?? "";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function decodeHtmlAttribute(value) {
|
|
139
|
+
return value
|
|
140
|
+
.replaceAll("&", "&")
|
|
141
|
+
.replaceAll(""", '"')
|
|
142
|
+
.replaceAll("'", "'")
|
|
143
|
+
.replaceAll("<", "<")
|
|
144
|
+
.replaceAll(">", ">");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 从官方播放器渲染后的 DOM 中提取并校验视频 CDN 地址。 */
|
|
148
|
+
export function parseOfficialVideoUrl(dom) {
|
|
149
|
+
const videoTag = dom.match(/<video\b[^>]*>/i)?.[0];
|
|
150
|
+
const rawSource = videoTag?.match(/\bsrc=["']([^"']+)["']/i)?.[1];
|
|
151
|
+
if (!rawSource) throw new Error("抖音官方播放器未返回视频地址,作品可能已删除、设为私密或触发风控");
|
|
152
|
+
|
|
153
|
+
const normalized = decodeHtmlAttribute(rawSource);
|
|
154
|
+
let url;
|
|
155
|
+
try {
|
|
156
|
+
url = new URL(normalized.startsWith("//") ? `https:${normalized}` : normalized);
|
|
157
|
+
} catch {
|
|
158
|
+
throw new Error("抖音官方播放器返回了无效的视频地址");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const allowed =
|
|
162
|
+
url.protocol === "https:" && ALLOWED_VIDEO_HOST_SUFFIXES.some((suffix) => url.hostname.endsWith(suffix));
|
|
163
|
+
if (!allowed) throw new Error(`抖音官方播放器返回了非预期的视频域名:${url.hostname}`);
|
|
164
|
+
return url.toString();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function renderOfficialPlayerDom(chrome, playerUrl) {
|
|
168
|
+
const { stdout } = await execFileAsync(
|
|
169
|
+
chrome,
|
|
170
|
+
[
|
|
171
|
+
"--headless=new",
|
|
172
|
+
"--no-sandbox",
|
|
173
|
+
"--disable-gpu",
|
|
174
|
+
`--virtual-time-budget=${PLAYER_VIRTUAL_TIME_MS}`,
|
|
175
|
+
"--dump-dom",
|
|
176
|
+
playerUrl,
|
|
177
|
+
],
|
|
178
|
+
{ timeout: PLAYER_LOAD_TIMEOUT_MS, maxBuffer: MAX_DOM_BYTES },
|
|
179
|
+
);
|
|
180
|
+
return stdout;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 由抖音官方播放器完成访客 Cookie 与动态签名,再读取真实 CDN 地址。 */
|
|
184
|
+
export async function resolveOfficialDouyinVideoUrl(
|
|
185
|
+
awemeId,
|
|
186
|
+
{ chrome = findChromeExecutable(), renderDom = renderOfficialPlayerDom } = {},
|
|
187
|
+
) {
|
|
188
|
+
if (!/^\d{15,}$/.test(awemeId)) throw new Error(`无效的抖音 aweme_id:${awemeId}`);
|
|
189
|
+
if (!chrome) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
"旧分享页已不再提供 video_id,且未找到 Chrome/Chromium。请安装 Chrome,或设置 DOUYIN_CHROME_EXECUTABLE 指向浏览器可执行文件。",
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const playerUrl = `https://open.douyin.com/player/video?vid=${awemeId}&autoplay=0`;
|
|
196
|
+
let lastError;
|
|
197
|
+
for (let attempt = 1; attempt <= PLAYER_ATTEMPTS; attempt += 1) {
|
|
198
|
+
try {
|
|
199
|
+
return parseOfficialVideoUrl(await renderDom(chrome, playerUrl));
|
|
200
|
+
} catch (error) {
|
|
201
|
+
lastError = error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
205
|
+
}
|
|
206
|
+
|
|
114
207
|
// ─── 下载逻辑 ────────────────────────────────────────────────────────────────
|
|
115
208
|
|
|
116
|
-
async function
|
|
209
|
+
async function fetchVideoSource(url, referer) {
|
|
210
|
+
return fetchWithRetry(
|
|
211
|
+
url,
|
|
212
|
+
{
|
|
213
|
+
headers: { "User-Agent": DESKTOP_UA, Referer: referer },
|
|
214
|
+
redirect: "follow",
|
|
215
|
+
},
|
|
216
|
+
{ timeoutMs: 60_000 },
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function download(input, outputDir) {
|
|
117
221
|
console.log("正在解析...");
|
|
118
222
|
|
|
119
223
|
const trimmed = input.trim();
|
|
120
224
|
let awemeId = "";
|
|
121
225
|
let videoId = "";
|
|
226
|
+
let playUrl;
|
|
227
|
+
let referer = "https://open.douyin.com/";
|
|
122
228
|
|
|
123
229
|
if (/^v0[a-z0-9]{10,}$/i.test(trimmed)) {
|
|
124
230
|
videoId = trimmed;
|
|
231
|
+
playUrl = buildPlayUrlNoWatermark(videoId);
|
|
232
|
+
referer = "https://www.douyin.com/";
|
|
125
233
|
} else {
|
|
126
234
|
awemeId = await resolveAwemeId(trimmed);
|
|
127
|
-
|
|
235
|
+
playUrl = await resolveOfficialDouyinVideoUrl(awemeId);
|
|
128
236
|
}
|
|
129
237
|
|
|
130
|
-
console.log(`aweme_id: ${awemeId || "-"} video_id: ${videoId}`);
|
|
131
|
-
|
|
132
|
-
const playUrl = buildPlayUrlNoWatermark(videoId);
|
|
238
|
+
console.log(`aweme_id: ${awemeId || "-"} video_id: ${videoId || "-"}`);
|
|
133
239
|
console.log("正在下载无水印视频...");
|
|
134
240
|
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
redirect: "follow",
|
|
138
|
-
}, { timeoutMs: 60_000 });
|
|
139
|
-
|
|
140
|
-
if (!resp.ok) {
|
|
141
|
-
throw new Error(`下载失败: HTTP ${resp.status}`);
|
|
142
|
-
}
|
|
241
|
+
const response = await fetchVideoSource(playUrl, referer);
|
|
242
|
+
if (!response.ok) throw new Error(`下载失败: HTTP ${response.status}`);
|
|
143
243
|
|
|
144
|
-
const
|
|
244
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
145
245
|
const filename = `douyin_${awemeId || videoId}.mp4`;
|
|
146
|
-
|
|
147
246
|
const dir = outputDir ? resolve(outputDir) : process.cwd();
|
|
148
247
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
149
248
|
|
|
150
249
|
const filepath = resolve(dir, filename);
|
|
151
|
-
writeFileSync(filepath,
|
|
250
|
+
writeFileSync(filepath, buffer);
|
|
152
251
|
|
|
153
|
-
const sizeMB = (
|
|
252
|
+
const sizeMB = (buffer.length / 1024 / 1024).toFixed(2);
|
|
154
253
|
console.log(`✅ 已保存: ${filepath} (${sizeMB} MB)`);
|
|
155
254
|
return filepath;
|
|
156
255
|
}
|
|
157
256
|
|
|
158
|
-
// ─── CLI 入口
|
|
257
|
+
// ─── CLI 入口 ────────────────────────────────────────────────────────────────
|
|
159
258
|
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
259
|
+
async function main() {
|
|
260
|
+
const argv = process.argv.slice(2);
|
|
261
|
+
const outputIndex = argv.indexOf("--output");
|
|
262
|
+
let outputDir;
|
|
263
|
+
if (outputIndex !== -1) {
|
|
264
|
+
outputDir = argv[outputIndex + 1];
|
|
265
|
+
argv.splice(outputIndex, 2);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const input = argv.join(" ").trim();
|
|
269
|
+
if (!input) {
|
|
270
|
+
console.error('用法: node douyin-download.mjs "<抖音链接>" [--output <目录>]');
|
|
271
|
+
process.exitCode = 2;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
167
274
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
275
|
+
try {
|
|
276
|
+
await download(input, outputDir);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
console.error("❌ 错误:", error.message || error);
|
|
279
|
+
process.exitCode = 1;
|
|
280
|
+
}
|
|
172
281
|
}
|
|
173
282
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
process.exit(1);
|
|
177
|
-
});
|
|
283
|
+
const entryPath = process.argv[1] ? resolve(process.argv[1]) : "";
|
|
284
|
+
if (entryPath === fileURLToPath(import.meta.url)) await main();
|