fix: support douban image uploads with referer

- use subject page referer for douban image requests\n- keep PicGo clipboard upload flow and await clipboard writes\n- add webp clipboard fallback for image decoding
This commit is contained in:
ChrsiGray 2026-03-30 16:04:39 +08:00 committed by wanxp
parent 318aabb21b
commit e8a0d8a00c
6 changed files with 125 additions and 72 deletions

@ -543,24 +543,20 @@ export default abstract class DoubanAbstractLoadHandler<T extends DoubanSubject>
}
fileName = this.parsePartPath(fileName, extract, context, variableMap)
fileName = fileName + fileNameSuffix;
const imageReferer = extract.url || extract.imageUrl || image;
const imageReferer = (extract.id ? this.getSubjectUrl(extract.id) : '') || extract.url;
const referHeaders = HttpUtil.buildImageRequestHeaders(
context.plugin.settingsManager.getHeaders() as Record<string, any>,
imageReferer,
image
imageReferer
);
if ((syncConfig ? syncConfig.cacheHighQuantityImage : context.settings.cacheHighQuantityImage) && context.userComponent.isLogin()) {
try {
const fileNameSpilt = fileName.split('.');
const highFilename = fileNameSpilt.first() + '.jpg';
const highImage = this.getHighQuantityImageUrl(highFilename);
const highImageFilename = this.getImageFilename(image);
const highImage = this.getHighQuantityImageUrl(highImageFilename);
const highImageHeaders = HttpUtil.buildImageRequestHeaders(
context.plugin.settingsManager.getHeaders() as Record<string, any>,
imageReferer,
highImage
imageReferer
);
const resultValue = await this.handleImage(highImage, folder, highFilename, context, false, highImageHeaders);
const resultValue = await this.handleImage(highImage, folder, fileName, context, false, highImageHeaders);
if (resultValue && resultValue.success) {
extract.image = resultValue.filepath;
this.initImageVariableMap(extract, context, variableMap);
@ -578,6 +574,14 @@ export default abstract class DoubanAbstractLoadHandler<T extends DoubanSubject>
}
}
private getImageFilename(image: string): string {
if (!image) {
return '';
}
const imageUrl = image.split('?').first() || image;
return imageUrl.substring(imageUrl.lastIndexOf('/') + 1);
}
private initImageVariableMap(extract: T, context: HandleContext, variableMap : Map<string, DataField>) {
variableMap.set(DoubanParameterName.IMAGE_URL, new DataField(
DoubanParameterName.IMAGE_URL,

@ -335,6 +335,8 @@ PS: This file could be delete if you want to.
'130105': `Can not use Douban this time, Please try again after 12 hour or 24 hour. Or you can reset your connection `,
'130106': `Can not use Douban this time, Please try Login In Douban Plugin. If not working please again after 12 hour or 24 hour. Or you can reset your connection `,
'130404': `404 Url Not Found`,
'130109': `Downloaded image is empty`,
'130110': `Clipboard write failed`,
'130107': `Can not find array setting for {1} in {0} , Please add it in array settings`,

@ -350,6 +350,8 @@ export default {
'130105': `由于多次频繁请求数据,豆瓣当前暂时不可用. 请于12小时或24小时后再重试或重置你的网络(如重新拨号或更换网络) `,
'130106': `请尝试在Douban插件中登录后操作. 若还是无效果则尝试于12小时或24小时后再重试或重置你的网络(如重新拨号或更换网络) `,
'130107': `参数{0}中指定的数组输出类型{1}不存在,请前往配置进行设置`,
'130109': `下载图片内容为空`,
'130110': `写入剪贴板失败`,
'130120': `同步时发生错误,但同步将会继续。错误项目是 {}。`,
'130121': `总数只有{0}, 选择的开始比总数还要大,将不会同步。`,

@ -5,6 +5,7 @@ import FileHandler from "../file/FileHandler";
import {FileUtil} from "../utils/FileUtil";
import HandleContext from "../douban/data/model/HandleContext";
import HttpUtil from "../utils/HttpUtil";
import {ClipboardUtil} from "../utils/ClipboardUtil";
import {ResultI} from "../utils/model/Result";
export default class NetFileHandler {
@ -16,16 +17,19 @@ export default class NetFileHandler {
async downloadDBFile(url: string, folder:string, filename: string, context:HandleContext, showError:boolean, headers?:any): Promise<{ success: boolean, error:string, filepath: string }> {
const filePath:string = FileUtil.join(folder, filename);
return HttpUtil.httpRequestBuffer(url, headers, context.plugin.settingsManager)
.then((response) => {
if (response.status == 404) {
throw new Error(i18nHelper.getMessage('130404'));
}
if (response.status == 403) {
throw new Error(i18nHelper.getMessage('130106'));
}
return response.textArrayBuffer;
})
return HttpUtil.httpRequestBuffer(url, headers, context.plugin.settingsManager)
.then((response) => {
if (response.status == 404) {
throw new Error(i18nHelper.getMessage('130404'));
}
if (response.status == 403) {
throw new Error(i18nHelper.getMessage('130106'));
}
if (response.status == 418) {
throw new Error(i18nHelper.getMessage('130105'));
}
return response.textArrayBuffer;
})
.then((buffer) => {
if (!buffer || buffer.byteLength == 0) {
return 0;
@ -55,19 +59,23 @@ export default class NetFileHandler {
async downloadDBUploadPicGoByClipboard(url: string, filename: string, context:HandleContext, showError:boolean, headers?:any): Promise<{ success: boolean, error:string, filepath: string }> {
return HttpUtil.httpRequestBuffer(url, headers, context.plugin.settingsManager)
.then((response) => {
if (response.status == 404) {
throw new Error(i18nHelper.getMessage('130404'));
}
if (response.status == 403) {
throw new Error(i18nHelper.getMessage('130106'));
}
if (response.status == 418) {
throw new Error(i18nHelper.getMessage('130105'));
}
return response.textArrayBuffer;
})
.then(async (buffer) => {
const tempFilePath = this.getPicGoTempFilePath(filename, context);
try {
await this.fileHandler.creatAttachmentWithData(tempFilePath.relativePath, buffer);
return await this.uploadClipboardFile(context, tempFilePath.absolutePath);
} finally {
await this.fileHandler.deleteFile(tempFilePath.relativePath);
}
.then(async (buffer) => {
if (!buffer || buffer.byteLength == 0) {
throw new Error(i18nHelper.getMessage('130109'));
}
await ClipboardUtil.writeImage(buffer);
return await this.uploadClipboardFile(context);
}).then((data) => {
if (data.success) {
return {success: true, error: '', filepath: HttpUtil.extractURLFromString(data.result[0])};
@ -90,23 +98,9 @@ export default class NetFileHandler {
}
private getPicGoTempFilePath(filename: string, context: HandleContext) {
const tempFileName = `${Date.now()}_${filename}`;
const relativePath = FileUtil.join(this.fileHandler.getTmpPath(), tempFileName);
// @ts-ignore
const adapter = context.plugin.app.vault.adapter;
// @ts-ignore
const basePath = adapter && adapter.getBasePath ? adapter.getBasePath() : this.fileHandler.getRootPath();
return {
relativePath: relativePath,
absolutePath: FileUtil.join(basePath, relativePath),
};
}
async uploadClipboardFile(context:HandleContext, filePath?: string): Promise<ResultI> {
const body = filePath ? JSON.stringify({list: [filePath]}) : null;
async uploadClipboardFile(context:HandleContext): Promise<ResultI> {
const response = await HttpUtil.httpRequest(
context.settings.pictureBedSetting.url, {}, context.plugin.settingsManager, {method: "post", body: body});
context.settings.pictureBedSetting.url, {}, context.plugin.settingsManager, {method: "post"});
const data = response.textJson as ResultI;
return data;
}
@ -124,3 +118,4 @@ export default class NetFileHandler {
}
}

@ -1,12 +1,78 @@
export class ClipboardUtil {
import {i18nHelper} from "../lang/helper";
public static async writeImage(data:ArrayBuffer, options: ClipboardOptions = defaultClipboardOptions) {
const { clipboard, nativeImage } = require('electron');
export class ClipboardUtil {
await clipboard.writeImage(nativeImage.createFromBuffer(data));
console.log(`Copied to clipboard as HTML`);
public static async writeImage(data:ArrayBuffer, options: ClipboardOptions = defaultClipboardOptions) {
if (!data || data.byteLength == 0) {
throw new Error(i18nHelper.getMessage('130110'));
}
const { clipboard, nativeImage } = require('electron');
const isWebp = this.isWebp(data);
let image = nativeImage.createFromBuffer(Buffer.from(data));
if ((!image || image.isEmpty()) && isWebp) {
image = await this.createNativeImageFromWebp(data);
}
if (!image || image.isEmpty()) {
throw new Error(i18nHelper.getMessage('130110'));
}
clipboard.clear();
clipboard.writeImage(image);
await new Promise(resolve => setTimeout(resolve, 100));
const clipboardImage = clipboard.readImage();
if (!clipboardImage || clipboardImage.isEmpty()) {
throw new Error(i18nHelper.getMessage('130110'));
}
console.log(`Copied to clipboard as HTML`);
}
private static isWebp(data: ArrayBuffer): boolean {
const bytes = new Uint8Array(data);
if (bytes.length < 12) {
return false;
}
return bytes[0] === 0x52
&& bytes[1] === 0x49
&& bytes[2] === 0x46
&& bytes[3] === 0x46
&& bytes[8] === 0x57
&& bytes[9] === 0x45
&& bytes[10] === 0x42
&& bytes[11] === 0x50;
}
private static async createNativeImageFromWebp(data: ArrayBuffer) {
const { nativeImage } = require('electron');
const imageElement = await this.loadImage(URL.createObjectURL(new Blob([data], {type: 'image/webp'})));
try {
const canvas = document.createElement('canvas');
canvas.width = imageElement.naturalWidth || imageElement.width;
canvas.height = imageElement.naturalHeight || imageElement.height;
const context = canvas.getContext('2d');
if (!context) {
throw new Error(i18nHelper.getMessage('130110'));
}
context.drawImage(imageElement, 0, 0);
return nativeImage.createFromDataURL(canvas.toDataURL('image/png'));
} finally {
imageElement.src = '';
}
}
private static loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(url);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error(i18nHelper.getMessage('130110')));
};
image.src = url;
});
}
}
@ -20,4 +86,3 @@ interface ClipboardOptions {
const defaultClipboardOptions: ClipboardOptions = {
contentType: 'text/plain',
}

@ -73,16 +73,14 @@ export default class HttpUtil {
}
}
public static buildImageRequestHeaders(headers: Record<string, any> = {}, referer?: string, imageUrl?: string): Record<string, string> {
public static buildImageRequestHeaders(headers: Record<string, any> = {}, referer?: string): Record<string, string> {
const nextHeaders: Record<string, string> = {};
let currentReferer = '';
Object.entries(headers || {}).forEach(([key, value]) => {
if (value == null || value === '') {
return;
}
const lowerKey = key.toLowerCase();
if (lowerKey === 'referer') {
currentReferer = String(value);
return;
}
if (this.IMAGE_REQUEST_HEADERS_TO_DROP.has(lowerKey) || lowerKey.startsWith('sec-ch-ua')) {
@ -90,25 +88,12 @@ export default class HttpUtil {
}
nextHeaders[key] = String(value);
});
const finalReferer = referer || currentReferer || this.getDefaultImageRequestReferer(imageUrl);
if (finalReferer) {
nextHeaders.Referer = finalReferer;
if (referer) {
nextHeaders.Referer = referer;
}
return nextHeaders;
}
private static getDefaultImageRequestReferer(imageUrl?: string): string {
if (!imageUrl) {
return '';
}
try {
const url = new URL(imageUrl);
return `${url.protocol}//${url.host}/`;
} catch (error) {
return '';
}
}
public static parse(url: string): { protocol: string, host: string, port: string, path: string } {
const regex = /^(.*?):\/\/([^\/:]+)(?::(\d+))?([^?]*)$/;
@ -140,10 +125,10 @@ export default class HttpUtil {
* @param str
*/
public static extractURLFromString(str: string): string {
const urlRegex = /(?:!\[.*?\]\()?(https?:\/\/[^\s)]+)/;
const matches = urlRegex.exec(str);
if (matches && matches[1]) {
return matches[1];
const urlRegex = /(?:!\[.*?\]\()?(https?:\/\/[^\s)]+)/g;
const matches = str.match(urlRegex);
if (matches && matches.length > 0) {
return matches[0];
}
return str;
}