Use remote avatar URLs instead of downloading avatars

This commit is contained in:
wolfmanster 2026-08-19 16:50:25 +08:00
parent 52c702a4be
commit dba0ec9c84
4 changed files with 3 additions and 36 deletions

View file

@ -18,7 +18,7 @@
1. 向本地 Cobalt `POST /` 请求媒体列表,并强制通过 Cobalt 代理媒体。
2. 请求 X 的公开嵌入数据端点 `cdn.syndication.twimg.com/tweet-result`,补齐 7 个元数据字段。
公开嵌入端点无需 Cookie、账号和官方 API key,但不是 X 承诺稳定的正式 API。它不可用时任务会明确失败并允许重试,避免生成缺字段的半成品记录。头像会下载到项目内,不由浏览器直接请求远端。
公开嵌入端点无需 Cookie、账号和官方 API key,但不是 X 承诺稳定的正式 API。它不可用时任务会明确失败并允许重试,避免生成缺字段的半成品记录。博主头像只使用元数据中的远程地址,不下载到项目内。
## 目录

View file

@ -23,13 +23,10 @@ app.use(cors());
app.use(express.json({ limit: '1mb' }));
function publicJob(job: DownloadJob) {
const { avatarPath, ...visibleJob } = job;
const { avatarPath: _legacyAvatarPath, ...visibleJob } = job as DownloadJob & { avatarPath?: string };
return {
...visibleJob,
metadata: job.metadata ? {
...job.metadata,
avatarUrl: avatarPath ? `/api/jobs/${job.id}/avatar` : '',
} : undefined,
metadata: job.metadata,
media: job.media.map(({ sourceUrl: _sourceUrl, localPath: _localPath, ...media }) => ({
...media,
previewUrl: `/api/jobs/${job.id}/media/${media.id}`,
@ -110,15 +107,6 @@ app.post('/api/jobs/:id/retry', (req, res) => {
app.delete('/api/history', async (_req, res) => res.json({ removed: await store.clearCompleted() }));
app.get('/api/jobs/:jobId/avatar', (req, res) => {
const job = store.get(req.params.jobId);
if (!job?.avatarPath) return res.status(404).end();
const filePath = path.resolve(store.dataDir, job.avatarPath);
if (!filePath.startsWith(`${path.resolve(store.mediaDir)}${path.sep}`) || !fs.existsSync(filePath)) return res.status(404).end();
res.setHeader('Cache-Control', 'private, max-age=3600');
return fs.createReadStream(filePath).pipe(res);
});
app.get('/api/jobs/:jobId/media/:mediaId', async (req, res) => {
const job = store.get(req.params.jobId);
const media = job?.media.find((item) => item.id === req.params.mediaId);

View file

@ -101,8 +101,6 @@ export class DownloadQueue extends EventEmitter {
this.store.update(jobId, { metadata, media, status: 'downloading', progress: 12 });
this.emitChange();
await this.downloadAvatar(job, metadata.avatarUrl, controller.signal);
for (let index = 0; index < media.length; index += 1) {
await this.downloadMedia(job, media[index]!, index, media.length, controller.signal);
}
@ -126,24 +124,6 @@ export class DownloadQueue extends EventEmitter {
}
}
private async downloadAvatar(job: DownloadJob, avatarUrl: string, signal: AbortSignal): Promise<void> {
if (!avatarUrl) return;
try {
const response = await fetch(avatarUrl, { signal });
if (!response.ok) return;
const contentType = response.headers.get('content-type')?.split(';')[0];
const extension = contentType ? (EXTENSIONS[contentType] ?? 'jpg') : 'jpg';
const jobDir = path.join(this.store.mediaDir, job.id);
await fsp.mkdir(jobDir, { recursive: true });
const avatarPath = path.join(jobDir, `avatar.${extension}`);
await fsp.writeFile(avatarPath, Buffer.from(await response.arrayBuffer()));
this.store.update(job.id, { avatarPath: path.relative(this.store.dataDir, avatarPath) });
} catch (error) {
if (signal.aborted) throw error;
// Avatar failure does not discard otherwise valid post media.
}
}
private async downloadMedia(
job: DownloadJob,
media: MediaItem,

View file

@ -36,7 +36,6 @@ export interface DownloadJob {
status: JobStatus;
progress: number;
metadata?: PostMetadata;
avatarPath?: string;
media: MediaItem[];
error?: string;
attempts: number;