Skip to content

Commit ca6aace

Browse files
committed
feat(core): 升级多源防盗链穿透架构、图片加载性能优化与多语言字体自适应
1. 网络与防盗链代理层升级: - 引入 declarativeNetRequest 动态拦截规则,为微博、Pixiv、Reddit 图片自动注入和伪装安全 Referer & Origin 头。 - 彻底移除后台 Service Worker 中不兼容的 FileReader,改用基于 ArrayBuffer 与 Uint8Array 的原生二进制分块处理机制,解决受限域名图片代理加载崩溃问题。 - 在后台代理 Fetch 通道中增加 50MB 安全体积截断校验,抵御超大媒体导致的 SW 内存溢出风险。 2. 提取解析算法与容错增强: - 优化 WeiboResolver,利用 Base36 进制解码微博图床文件名直接提取原图宽高,规避网络延迟。 - 扩展 PixivResolver,深度适配 sci/ci 前缀小说封面原图匹配及 JPG/PNG 智能双向回退重试机制,Pixiv 站点下自动过滤无用背景 SVG。 - 支持 Telegram 公开 CDN 原图还原,并为 Telegram 站点强制白名单开启智能 Blob 嗅探。 - 为图片尺寸测量流程增加超时放宽(2s~3s)及高容错兜底保留机制,防止测量失败导致原图项丢失。 3. 前端 UI 渲染与性能重构: - 重构受限域名图片加载 Hook,采用派生状态(Derived State)同步渲染模式配合全局 Promise 级请求去重,消除多次渲染造成的屏幕闪烁与重绘。 - Hook 内集成 Object URL 全局 Map 缓存并加入 500 个上限的容量控制(自动调用 revokeObjectURL),防止长会话隐性内存泄漏。 - 优化虚拟列表组件复用时的卸载状态重置,规避 Ghost Images(闪现旧图)现象。 4. 国际化与工程规范: - 建立 AGENTS.md(项目宪法)定义多语言后备字体栈规范及开发禁止模式。 - 在 theme.ts 中通过运行时 JS 实现 Outfit 与中日韩(CJK)字体的优雅自适应降级,消除字体交叉污染并缩减 CSS 编译体积。 - 补全并同步 8 种语言包的 Telegram 暗号化聊天图片嗅探指引文案。 - 补全 Vite 构建配置 manifest.json 的 web_accessible_resources 动态 Chunk 白名单通配符规则 (*.js/*.css),防止打包分块被 Chrome 安全策略拦截。
1 parent 741bbc3 commit ca6aace

31 files changed

Lines changed: 640 additions & 58 deletions

AGENTS.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Imaget Project Constitution (宪法)
2+
3+
## 技术栈与版本约束
4+
* **Core**: React 19, TypeScript
5+
* **UI**: Mantine v8, Shadow DOM
6+
* **Chrome Extension**: Manifest V3
7+
8+
## 命名与编码规范
9+
* **字体变量**: 使用 `--imaget-font-family` 作为多语言自适应字体的核心 CSS 变量。
10+
* **多语言回退栈**:
11+
* 英文/西方: `Outfit`, `system-ui`
12+
* 简体中文: `PingFang SC`, `Microsoft YaHei`
13+
* 繁体中文: `PingFang TC`, `Microsoft JhengHei`
14+
* 日语: `Hiragino Sans`, `Meiryo`
15+
* 韩语: `Apple SD Gothic Neo`, `Malgun Gothic`
16+
17+
## 禁止模式
18+
* 严禁混合 DOM 抓取逻辑与 `src/ui` 组件。
19+
* 严禁在 Shadow DOM 内直接引用外部不可达的第三方 CSS,除非在 Sidepanel 且通过官方扩展方式。
20+
* 严禁对所有语言硬编码相同的静态回退字体栈,避免 CJK 汉字冲突。

public/manifest.json

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
"activeTab",
1717
"storage",
1818
"contextMenus",
19-
"sidePanel"
19+
"sidePanel",
20+
"declarativeNetRequest"
2021
],
2122
"host_permissions": [
2223
"http://*/*",
@@ -51,13 +52,11 @@
5152
"web_accessible_resources": [
5253
{
5354
"resources": [
54-
"content.js",
55-
"theme.js",
56-
"types.js",
57-
"favicon.svg",
58-
"icon-16.png",
59-
"icon-48.png",
60-
"icon-128.png"
55+
"*.js",
56+
"*.css",
57+
"*.svg",
58+
"*.png",
59+
"*.ico"
6160
],
6261
"matches": [
6362
"http://*/*",

sidepanel.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7+
<link rel="preconnect" href="https://fonts.googleapis.com">
8+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9+
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
710
<title>Imaget Side Panel</title>
811
<style>
912
body, html { margin: 0; padding: 0; width: 100%; height: 100%; background: #242424; color: #fff; }

src/core/platform.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { WebAdapter } from "./adapters/web";
2+
import { ExtensionAdapter } from "./adapters/extension";
3+
import type { IPlatformAdapter } from "./adapters/interface";
4+
5+
const isExtension = typeof chrome !== "undefined" && chrome.runtime?.id;
6+
export const platformAdapter: IPlatformAdapter = isExtension
7+
? new ExtensionAdapter()
8+
: new WebAdapter();

src/core/processor.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,15 @@ export class ImageProcessor {
100100
) {
101101
return false;
102102
}
103+
// 微博、Pixiv 和 Reddit 具有极其严格的防盗链,且 chrome.downloads.download 在网络底层无法被拦截修改 Referer/Origin,
104+
// 因此必须强制走 background.js 的 Blob 代理抓取模式,才能下载到真正的原图而非 403 报错 htm 网页!
105+
const isProtectedDomain =
106+
img.url.includes("sinaimg.cn") ||
107+
img.url.includes("pximg.net") ||
108+
img.url.includes("redd.it");
109+
if (isProtectedDomain) {
110+
return false;
111+
}
103112
return true;
104113
}
105114

src/core/resolvers/pixiv.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,29 @@ export class PixivResolver implements IUrlResolver {
44
readonly name = "Pixiv";
55

66
matches(url: string): boolean {
7-
return url.includes("i.pximg.net") && url.includes("/c/");
7+
return url.includes("i.pximg.net");
88
}
99

1010
resolve(url: string): string {
11-
return url
12-
.replace(/\/c\/[^/]+/, "")
13-
.replace(/\/img-master\//, "/img-original/")
14-
.replace(/_master\d+/, "");
11+
const isNovelCover = /\/(sci|ci)\d+_[a-zA-Z0-9]+/.test(url);
12+
13+
// 1. 小说封面等带有 sci/ci 前缀的图片原图存放在 /novel-cover-original/ 目录下,其他常规图片存放在 /img-original/ 目录下
14+
const originalDir = isNovelCover ? "/novel-cover-original/" : "/img-original/";
15+
16+
let resolved = url
17+
.replace(/\/c\/[^/]+\/[^/]+/, "") // 匹配双层缩略路径
18+
.replace(/\/c\/[^/]+/, "") // 匹配单层缩略路径
19+
.replace(/\/(img-master|custom-thumb)\//, originalDir)
20+
.replace(/i\.pximg\.net\/img\/(\d{4})\//, `i.pximg.net${originalDir}img/$1/`); // 适配小说/插画封面路径,锚定域名防递归
21+
22+
// 2. 剥离文件名中的尺寸后缀,如 _p0_master1200, _p0_custom1200, _p0_square1200, _master1200
23+
resolved = resolved.replace(/_(master|custom|square)\d+/, "");
24+
25+
// 3. 特殊处理:如果是小说封面或企划插图(sci/ci),由于原图大部分为无损 png,如果是 jpg,我们这里直接先转为 .png 提升一次性加载成功率
26+
if (isNovelCover && resolved.endsWith(".jpg")) {
27+
resolved = resolved.replace(/\.jpg$/, ".png");
28+
}
29+
30+
return resolved;
1531
}
1632
}

src/core/resolvers/telegram.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ export class TelegramResolver implements IUrlResolver {
44
readonly name = "Telegram";
55

66
matches(url: string): boolean {
7-
return url.includes("cdn-telegram.org") || url.includes("t.me/s/");
7+
return (
8+
url.includes("cdn-telegram.org") ||
9+
url.includes("t.me/s/") ||
10+
url.includes("telesco.pe") ||
11+
url.includes("telegram-cdn.org")
12+
);
813
}
914

1015
resolve(url: string): string {

src/core/resolvers/weibo.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,34 @@ export class WeiboResolver implements IUrlResolver {
44
readonly name = "Weibo";
55

66
matches(url: string): boolean {
7-
return url.includes("weibo.com") || url.includes("sinaimg.cn");
7+
return url.includes("sinaimg.cn");
88
}
99

1010
resolve(url: string): string {
11-
return url.replace(/\/(mw\d+|thumbnail|orj\d+|square)\//, "/large/");
11+
const resolved = url.replace(
12+
/\/(mw\d+|thumbnail|orj\d+|square|bmiddle|woriginal|small|thumb\d+|wap\d+|crop\.\d+\.\d+\.\d+\.\d+\.\d+)\//,
13+
"/large/"
14+
);
15+
return resolved.split("?")[0];
16+
}
17+
18+
static parseDimensions(url: string): { width: number; height: number } | null {
19+
try {
20+
const match = url.match(/\/([a-zA-Z0-9]{8,22})(ly1|gy1|gy3|ly3|my1|j6|j2|j3|j|g|mw\d+|orj\d+)?([a-zA-Z0-9]{3})([a-zA-Z0-9]{3})([a-zA-Z0-9]{2,4})\.(jpg|jpeg|png|webp|gif)$/i);
21+
if (!match) return null;
22+
23+
const widthStr = match[3];
24+
const heightStr = match[4];
25+
26+
const width = parseInt(widthStr, 36);
27+
const height = parseInt(heightStr, 36);
28+
29+
if (width > 16 && height > 16 && width < 10000 && height < 10000) {
30+
return { width, height };
31+
}
32+
} catch {
33+
// ignore
34+
}
35+
return null;
1236
}
1337
}

src/core/sniffer.ts

Lines changed: 106 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { type ImageItem, type ImageFormat } from "../types";
22
import { UrlResolver } from "./utils/url-resolver";
33
import { ImageTypeDetector } from "./utils/image-type-detector";
44
import { runConcurrent } from "./utils/concurrency";
5+
import { WeiboResolver } from "./resolvers/weibo";
56
import {
67
isDomainDisabled,
78
type SnifferSettings,
@@ -25,8 +26,8 @@ import {
2526
} from "../ui/utils/sniffer-events";
2627

2728
const METADATA_CONCURRENCY = 12;
28-
const IMAGE_METADATA_TIMEOUT_MS = 1500;
29-
const FETCH_METADATA_TIMEOUT_MS = 1000;
29+
const IMAGE_METADATA_TIMEOUT_MS = 3000;
30+
const FETCH_METADATA_TIMEOUT_MS = 2000;
3031

3132
/** 为字符串生成稳定的数字哈希(不依赖索引位置) */
3233
function stableHash(str: string): string {
@@ -545,7 +546,7 @@ export class Sniffer {
545546
action: "SNIFF_REQUEST",
546547
payload: { settings, requestId: options?.requestId },
547548
},
548-
(response) => {
549+
async (response) => {
549550
if (chrome.runtime.lastError || !response) {
550551
const errMsg = chrome.runtime.lastError?.message || "";
551552
if (!errMsg.includes("Receiving end does not exist")) {
@@ -556,7 +557,28 @@ export class Sniffer {
556557
}
557558
resolve([]);
558559
} else {
559-
resolve(response.results || []);
560+
const results: ImageItem[] = response.results || [];
561+
const updatedResults = [...results];
562+
563+
// 在侧边栏环境重新获取防盗链域名的真实宽高(DNR 规则已生效,可正常加载)
564+
await runConcurrent(updatedResults, METADATA_CONCURRENCY, async (item, index) => {
565+
const needUpdate =
566+
item.url.includes("sinaimg.cn") ||
567+
item.url.includes("weibo.com");
568+
if (needUpdate) {
569+
const meta = await this.getImageMetadata(item.url);
570+
if (meta) {
571+
updatedResults[index] = {
572+
...item,
573+
width: meta.width,
574+
height: meta.height,
575+
sizeKB: meta.sizeKB > 0 ? meta.sizeKB : item.sizeKB,
576+
};
577+
}
578+
}
579+
});
580+
581+
resolve(updatedResults);
560582
}
561583
},
562584
);
@@ -588,13 +610,15 @@ export class Sniffer {
588610
settings?.interfaceBehavior?.searchAllFrames ?? true;
589611
const identifyBackground =
590612
settings?.interfaceBehavior?.identifyBackgroundImages ?? true;
613+
const isTelegramHost = window.location.host.includes("telegram");
591614
const identifyBlob =
592-
settings?.interfaceBehavior?.identifyBlobImages ?? false;
615+
(settings?.interfaceBehavior?.identifyBlobImages ?? false) || isTelegramHost;
593616

617+
const isPixiv = window.location.href.includes("pixiv.net");
594618
const [treeUrls, perfUrls, svgUrls] = await Promise.all([
595619
this.sniffNodeTree(document, searchAllFrames, identifyBackground),
596620
Promise.resolve(this.sniffPerformance()),
597-
Promise.resolve(this.sniffSVGElements()),
621+
Promise.resolve(isPixiv ? [] : this.sniffSVGElements()),
598622
]);
599623
[...treeUrls, ...perfUrls, ...svgUrls].forEach((url) => {
600624
if (
@@ -603,7 +627,16 @@ export class Sniffer {
603627
url.startsWith("data:") ||
604628
(identifyBlob && url.startsWith("blob:")))
605629
) {
606-
urls.add(url);
630+
const resolved = UrlResolver.transformSiteSpecificUrl(url);
631+
// 过滤掉 weibo.com 的网页链接(如 /u/false 或 /status/ 等非真实图片)
632+
if (resolved.includes("weibo.com") && !resolved.match(/\.(jpg|jpeg|png|gif|webp|svg)/i)) {
633+
return;
634+
}
635+
// 如果在 Pixiv 网站,过滤掉所有的 SVG 资源,防止 UI 背景渐变/图标等乱入
636+
if (isPixiv && (resolved.includes("image/svg+xml") || resolved.toLowerCase().includes(".svg"))) {
637+
return;
638+
}
639+
urls.add(resolved);
607640
}
608641
});
609642

@@ -624,17 +657,32 @@ export class Sniffer {
624657

625658
const items: ImageItem[] = [];
626659
metadataResults.forEach((metadata, index) => {
660+
const url = urlArray[index];
661+
// 优先复用已有 ID,否则生成新的稳定哈希
662+
const id = existingIdMap.get(url) ?? stableHash(url);
663+
627664
if (metadata) {
628-
const url = urlArray[index];
629-
// 优先复用已有 ID,否则生成新的稳定哈希
630-
const id = existingIdMap.get(url) ?? stableHash(url);
631665
items.push({
632666
...metadata,
633667
id,
634668
isSelected: false,
635669
pageTitle: document.title,
636670
pageUrl: window.location.href,
637671
});
672+
} else {
673+
// 兜底保留:如果测量超时或失败,不直接丢弃图片,而是生成兜底的 ImageItem!
674+
items.push({
675+
url,
676+
width: 0,
677+
height: 0,
678+
sizeKB: 0,
679+
format: ImageTypeDetector.getFormatFromUrl(url),
680+
filename: url.split("/").pop()?.split(/[?#]/)[0] || "image",
681+
id,
682+
isSelected: false,
683+
pageTitle: document.title,
684+
pageUrl: window.location.href,
685+
});
638686
}
639687
});
640688

@@ -658,6 +706,18 @@ export class Sniffer {
658706
private async getImageMetadata(
659707
url: string,
660708
): Promise<Omit<ImageItem, "id" | "isSelected"> | null> {
709+
const weiboSize = WeiboResolver.parseDimensions(url);
710+
if (weiboSize) {
711+
return {
712+
url,
713+
width: weiboSize.width,
714+
height: weiboSize.height,
715+
sizeKB: 0,
716+
format: ImageTypeDetector.getFormatFromUrl(url),
717+
filename: url.split("/").pop()?.split(/[?#]/)[0] || "image",
718+
};
719+
}
720+
661721
try {
662722
const dimensions = await new Promise<{ width: number; height: number }>(
663723
(resolve, reject) => {
@@ -695,7 +755,42 @@ export class Sniffer {
695755
new Error(`Failed to load image: ${String(url).slice(0, 100)}`),
696756
);
697757
};
698-
img.src = url;
758+
// 仅侧边栏页面(chrome-extension:// 协议)才需要走后台 Blob 代理。
759+
// Content script 运行在目标页上下文,可直接加载且受 DNR 规则保护,无需代理。
760+
// 对于本地内存中的 blob: 图片,后台 Service Worker 无法跨越沙盒拉取,直接不走代理。
761+
const isSidePanelPage =
762+
typeof window !== "undefined" &&
763+
window.location.protocol === "chrome-extension:" &&
764+
typeof chrome !== "undefined" &&
765+
!!chrome.runtime?.sendMessage;
766+
767+
const isBlobUrl = url.startsWith("blob:");
768+
769+
if (isSidePanelPage && !isBlobUrl) {
770+
// 根据域名传入对应的防盗链 Referer,Service Worker fetch 不受 DNR 规则覆盖
771+
let referer: string | undefined;
772+
if (url.includes("sinaimg.cn") || url.includes("weibo.com")) {
773+
referer = "https://weibo.com/";
774+
} else if (url.includes("pximg.net")) {
775+
referer = "https://www.pixiv.net/";
776+
}
777+
chrome.runtime.sendMessage(
778+
{
779+
type: "FETCH_BLOB",
780+
payload: { url, referer },
781+
},
782+
(response) => {
783+
if (response?.success && response.arrayBuffer) {
784+
const mimeType = response.mimeType || "image/jpeg";
785+
img.src = `data:${mimeType};base64,${response.arrayBuffer}`;
786+
} else {
787+
img.src = url;
788+
}
789+
},
790+
);
791+
} else {
792+
img.src = url;
793+
}
699794
},
700795
);
701796

0 commit comments

Comments
 (0)