Skip to content

Commit d80d7d2

Browse files
committed
creat
1 parent eb0bb16 commit d80d7d2

14 files changed

Lines changed: 1745 additions & 0 deletions

File tree

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.DS_Store
2+
node_modules/
3+
*.zip
4+
*.crx
5+
*.pem

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Shuo Zhao
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

background.js

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
// TikDownload Background - Service Worker
2+
const LOG = (...args) => console.log('[TikDownload BG]', ...args);
3+
4+
// --- Saved API parameters (captured from webRequest) ---
5+
const savedParams = new Map(); // timestamp -> [[key, value], ...]
6+
const PARAMS_TO_SKIP = new Set(['a_bogus', 'fp', 'verifyFp', 'msToken']);
7+
8+
// --- Capture Douyin API request parameters ---
9+
function setupWebRequestListener() {
10+
chrome.webRequest.onBeforeRequest.addListener(
11+
(details) => {
12+
if (details.initiator && details.initiator.includes('extension://')) return;
13+
const url = details.url;
14+
if (!url) return;
15+
16+
try {
17+
const params = new URL(url).searchParams;
18+
const entries = Array.from(params.entries());
19+
if (entries.length < 20) return; // Real API calls have many params
20+
21+
savedParams.set(Date.now(), entries);
22+
LOG('Captured API params, total saved:', savedParams.size);
23+
24+
// Keep only last 20 entries
25+
if (savedParams.size > 20) {
26+
const oldest = savedParams.keys().next().value;
27+
savedParams.delete(oldest);
28+
}
29+
} catch (e) {}
30+
},
31+
{ urls: ['https://*.douyin.com/aweme/v1/web/*', 'https://*.tiktok.com/api/*'] }
32+
);
33+
}
34+
35+
// --- Get latest saved params ---
36+
function getLatestParams() {
37+
if (savedParams.size === 0) return null;
38+
const keys = Array.from(savedParams.keys());
39+
const latest = keys[keys.length - 1];
40+
return savedParams.get(latest);
41+
}
42+
43+
// --- Build Douyin detail API URL ---
44+
function buildDouyinDetailUrl(vid, params) {
45+
const url = new URL('https://www.douyin.com/aweme/v1/web/aweme/detail/');
46+
for (const [key, value] of params) {
47+
if (key === 'aweme_id') {
48+
url.searchParams.append('aweme_id', vid);
49+
} else {
50+
url.searchParams.append(key, value);
51+
}
52+
}
53+
if (!url.searchParams.has('aweme_id')) {
54+
url.searchParams.append('aweme_id', vid);
55+
}
56+
return url.toString();
57+
}
58+
59+
// --- Fetch aweme detail ---
60+
async function fetchAwemeDetail(vid) {
61+
const params = getLatestParams();
62+
if (!params) {
63+
LOG('No saved params available');
64+
return null;
65+
}
66+
67+
const url = buildDouyinDetailUrl(vid, params);
68+
LOG('Fetching aweme detail:', url.substring(0, 100));
69+
70+
for (let attempt = 0; attempt < 3; attempt++) {
71+
try {
72+
const resp = await fetch(url);
73+
if (!resp.ok) {
74+
LOG('Fetch attempt', attempt + 1, 'failed:', resp.status);
75+
continue;
76+
}
77+
const text = await resp.text();
78+
if (!text || text.length === 0) continue;
79+
const data = JSON.parse(text);
80+
if (data.aweme_detail) return data.aweme_detail;
81+
} catch (e) {
82+
LOG('Fetch error:', e.message);
83+
}
84+
await new Promise(r => setTimeout(r, 200));
85+
}
86+
87+
// Fallback: ask content script to fetch via page context
88+
LOG('Service worker fetch failed, trying page context...');
89+
try {
90+
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
91+
if (tab?.id) {
92+
await chrome.tabs.sendMessage(tab.id, {
93+
type: 'FETCH_AWEME_DETAIL_REQ',
94+
data: { url }
95+
});
96+
await new Promise(r => setTimeout(r, 2000));
97+
}
98+
} catch (e) {}
99+
100+
return null;
101+
}
102+
103+
// --- Extract video info from aweme_detail ---
104+
function extractVideoInfo(detail) {
105+
if (!detail) return null;
106+
107+
const info = {
108+
vid: detail.aweme_id || '',
109+
creator: detail.author?.nickname || '',
110+
description: detail.desc || '',
111+
timestamp: detail.create_time || 0,
112+
videos: [],
113+
images: []
114+
};
115+
116+
// Video with bit_rate (best quality)
117+
if (detail.video?.bit_rate?.length > 0) {
118+
const mp4Rates = detail.video.bit_rate
119+
.filter(r => r.format === 'mp4' || !r.format)
120+
.sort((a, b) => (b.bit_rate || 0) - (a.bit_rate || 0));
121+
122+
if (mp4Rates.length > 0) {
123+
const best = mp4Rates[0];
124+
const urlList = best.play_addr?.url_list || [];
125+
if (urlList.length > 0) {
126+
// Pick shortest URL (usually no watermark)
127+
const sorted = [...urlList].sort((a, b) => a.length - b.length);
128+
info.videos.push(sorted[0]);
129+
}
130+
}
131+
}
132+
133+
// Fallback: play_addr directly
134+
if (info.videos.length === 0 && detail.video?.play_addr?.url_list?.length > 0) {
135+
info.videos.push(detail.video.play_addr.url_list[0]);
136+
}
137+
138+
// Images (photo slideshow / 图集)
139+
if (detail.images && detail.images.length > 0) {
140+
for (const img of detail.images) {
141+
// Animated image (动图) has video
142+
if (img.video?.play_addr?.url_list?.length > 0) {
143+
const urls = img.video.play_addr.url_list;
144+
info.videos.push(urls[urls.length - 1] || urls[0]);
145+
} else if (img.url_list?.length > 0) {
146+
// Static image - prefer jpeg
147+
const url = pickBestImageUrl(img.url_list);
148+
if (url) info.images.push(url);
149+
}
150+
}
151+
// If we got images, clear the video (it's just a slideshow preview)
152+
if (info.images.length > 0 || info.videos.length > 1) {
153+
// Keep only image/动图 URLs, remove the slideshow preview video
154+
if (detail.images.length > 0 && info.videos.length === 1 && !detail.video?.bit_rate?.length) {
155+
info.videos = [];
156+
}
157+
}
158+
}
159+
160+
// image_post_info (alternative structure)
161+
if (info.images.length === 0 && detail.image_post_info?.images?.length > 0) {
162+
for (const img of detail.image_post_info.images) {
163+
const urlList = img.display_image?.url_list || img.url_list || [];
164+
const url = pickBestImageUrl(urlList);
165+
if (url) info.images.push(url);
166+
}
167+
}
168+
169+
return info;
170+
}
171+
172+
function pickBestImageUrl(urlList) {
173+
if (!urlList || urlList.length === 0) return '';
174+
for (const url of urlList) {
175+
if (url.includes('.jpeg') || url.includes('.jpg') || url.includes('.png')) return url;
176+
}
177+
for (const url of urlList) {
178+
if (!url.includes('webp')) return url;
179+
}
180+
return urlList[urlList.length - 1] || urlList[0];
181+
}
182+
183+
// --- Build filename ---
184+
function sanitize(name) {
185+
return name.replace(/[<>:"/\\|?*\n\r\t]/g, '_').replace(/_+/g, '_').trim().substring(0, 150);
186+
}
187+
188+
async function buildFilename(info, url, index) {
189+
const ext = (url.includes('.mp4') || url.includes('video')) ? 'mp4'
190+
: url.includes('.png') ? 'png' : 'jpg';
191+
192+
const settings = await chrome.storage.local.get(['folder', 'filenameBlocks']);
193+
const folder = settings.folder || 'TikDownload';
194+
const blocks = settings.filenameBlocks || ['caption', 'sep_', 'date', 'sep_', 'author'];
195+
196+
const date = info.timestamp ? new Date(info.timestamp * 1000).toISOString().slice(0, 10) : '';
197+
const author = info.creator || 'unknown';
198+
const caption = info.description ? info.description.substring(0, 50).trim() : '';
199+
200+
const values = {
201+
caption: caption || '',
202+
date: date,
203+
author: author,
204+
vid: info.vid || '',
205+
index: String(index),
206+
'sep_': '_',
207+
'sep-': '-'
208+
};
209+
210+
let parts = blocks.map(b => values[b] ?? '').filter(v => v !== '');
211+
let filename = parts.join('');
212+
213+
// Fallback if empty
214+
if (!filename || filename.replace(/[_-]/g, '').length === 0) {
215+
filename = info.vid || author + '_' + Date.now();
216+
}
217+
218+
filename = sanitize(filename) + '.' + ext;
219+
return `${folder}/${sanitize(author)}/${filename}`;
220+
}
221+
222+
// --- Download ---
223+
async function downloadPost(vid) {
224+
LOG('Download requested for vid:', vid);
225+
226+
const detail = await fetchAwemeDetail(vid);
227+
if (!detail) {
228+
return { ok: false, error: 'Failed to fetch video detail. Browse more to capture API params.' };
229+
}
230+
231+
const info = extractVideoInfo(detail);
232+
if (!info || (info.videos.length === 0 && info.images.length === 0)) {
233+
return { ok: false, error: 'No downloadable media found' };
234+
}
235+
236+
LOG('Found:', info.videos.length, 'videos,', info.images.length, 'images');
237+
LOG('Creator:', info.creator, 'Desc:', info.description?.substring(0, 30));
238+
239+
const allUrls = [...info.videos, ...info.images];
240+
let count = 0;
241+
242+
for (let i = 0; i < allUrls.length; i++) {
243+
const url = allUrls[i];
244+
const filename = await buildFilename(info, url, i + 1);
245+
LOG('Downloading:', filename);
246+
247+
try {
248+
await new Promise((resolve, reject) => {
249+
chrome.downloads.download(
250+
{ url, filename, conflictAction: 'uniquify' },
251+
(downloadId) => {
252+
if (chrome.runtime.lastError) {
253+
LOG('Download error:', chrome.runtime.lastError.message);
254+
reject(chrome.runtime.lastError.message);
255+
} else {
256+
LOG('Download started, id:', downloadId);
257+
count++;
258+
resolve(downloadId);
259+
}
260+
}
261+
);
262+
});
263+
} catch (e) {
264+
LOG('Download failed for item', i, ':', e);
265+
}
266+
267+
if (i < allUrls.length - 1) await new Promise(r => setTimeout(r, 300));
268+
}
269+
270+
return { ok: count > 0, count };
271+
}
272+
273+
// --- Message handler ---
274+
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
275+
if (msg.type === 'DOWNLOAD_VIDEO_REQ') {
276+
const { vid } = msg.data;
277+
downloadPost(vid).then(sendResponse).catch(e => sendResponse({ ok: false, error: e.message }));
278+
return true;
279+
}
280+
});
281+
282+
// --- Init ---
283+
setupWebRequestListener();
284+
LOG('TikDownload background initialized');

content.css

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
.tikdownload-btn {
2+
position: absolute;
3+
top: 12px;
4+
right: 12px;
5+
width: 36px;
6+
height: 36px;
7+
cursor: pointer;
8+
z-index: 999;
9+
display: none;
10+
border-radius: 50%;
11+
background: rgba(0, 0, 0, 0.6);
12+
padding: 8px;
13+
transition: transform 0.2s, opacity 0.2s, background 0.2s;
14+
align-items: center;
15+
justify-content: center;
16+
}
17+
18+
.tikdownload-btn:hover {
19+
transform: scale(1.15);
20+
background: rgba(254, 44, 85, 0.8);
21+
}
22+
23+
.tikdownload-btn-fixed {
24+
position: fixed !important;
25+
top: 80px !important;
26+
right: 20px !important;
27+
z-index: 2147483647 !important;
28+
display: flex !important;
29+
width: 44px !important;
30+
height: 44px !important;
31+
cursor: pointer;
32+
border-radius: 50%;
33+
background: rgba(0, 0, 0, 0.7);
34+
padding: 10px;
35+
transition: transform 0.2s, background 0.2s;
36+
align-items: center;
37+
justify-content: center;
38+
}
39+
40+
.tikdownload-btn-fixed:hover {
41+
transform: scale(1.15);
42+
background: rgba(254, 44, 85, 0.8);
43+
}

0 commit comments

Comments
 (0)