ehbrowser 1.0.0
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/LICENSE +202 -0
- package/README.md +531 -0
- package/dist/api/contract.js +7 -0
- package/dist/api/dto/auth.js +5 -0
- package/dist/api/dto/common.js +6 -0
- package/dist/api/dto/download.js +5 -0
- package/dist/api/dto/favorite.js +5 -0
- package/dist/api/dto/gallery.js +43 -0
- package/dist/api/dto/index.js +6 -0
- package/dist/api/dto/library.js +5 -0
- package/dist/api/dto/log.js +5 -0
- package/dist/api/dto/playlist.js +8 -0
- package/dist/api/dto/settings.js +5 -0
- package/dist/api/dto/storage.js +5 -0
- package/dist/api/dto/translate.js +6 -0
- package/dist/api/envelope.js +41 -0
- package/dist/api/events.js +20 -0
- package/dist/api/index.js +12 -0
- package/dist/api/routes.js +516 -0
- package/dist/config/atomic.js +85 -0
- package/dist/config/auth-setting.js +97 -0
- package/dist/config/cli.js +19 -0
- package/dist/config/db.js +168 -0
- package/dist/config/index.js +48 -0
- package/dist/config/json-store.js +219 -0
- package/dist/config/migrations.js +100 -0
- package/dist/config/schema.js +108 -0
- package/dist/config/user-setting.js +21 -0
- package/dist/config/validate.js +173 -0
- package/dist/eh/api.js +106 -0
- package/dist/eh/archives.js +100 -0
- package/dist/eh/auth.js +89 -0
- package/dist/eh/cookies.js +65 -0
- package/dist/eh/favorites.js +94 -0
- package/dist/eh/html.js +158 -0
- package/dist/eh/http.js +214 -0
- package/dist/eh/index.js +13 -0
- package/dist/eh/types.js +11 -0
- package/dist/eh/urls.js +81 -0
- package/dist/main.js +170 -0
- package/dist/platform/errors.js +76 -0
- package/dist/platform/open-external.js +90 -0
- package/dist/platform/os.js +28 -0
- package/dist/platform/paths.js +140 -0
- package/dist/server.js +1074 -0
- package/dist/services/auth-service.js +276 -0
- package/dist/services/config-service.js +227 -0
- package/dist/services/detail-cache.js +193 -0
- package/dist/services/detail-store.js +208 -0
- package/dist/services/download-file.js +127 -0
- package/dist/services/download-service.js +586 -0
- package/dist/services/favorite-service.js +237 -0
- package/dist/services/gallery-service.js +403 -0
- package/dist/services/local-library.js +471 -0
- package/dist/services/log-service.js +139 -0
- package/dist/services/playlist-service.js +85 -0
- package/dist/services/search-cache.js +78 -0
- package/dist/services/storage-service.js +27 -0
- package/dist/services/translate-service.js +208 -0
- package/dist/services/update-service.js +268 -0
- package/dist/services/upstream-service.js +64 -0
- package/dist/services/zip.js +210 -0
- package/dist/web/assets/index-C0tAHpmx.css +1 -0
- package/dist/web/assets/index-myYEnJ1q.js +4 -0
- package/dist/web/index.html +13 -0
- package/package.json +63 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 收藏服务。
|
|
3
|
+
* 本地部分(收藏夹与夹内条目)落在 SQLite,离线可用;云端部分(槽位 0~9 的名称、数量、
|
|
4
|
+
* 夹内画廊)向 favorites.php 取,未登录或上游不可达时只给本地那份,并把原因带回去。
|
|
5
|
+
* 「标记号」指条目上的 slot:修改该字段即把这条收藏挪到另一个云端分类,登录时同步到上游。
|
|
6
|
+
*/
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { activeAccount } from "../config/index.js";
|
|
9
|
+
import { fetchFavoriteCategories, fetchFavoriteGalleryPairs, LoginRequiredError, setFavorite, } from "../eh/index.js";
|
|
10
|
+
import { describeError } from "../platform/errors.js";
|
|
11
|
+
/** 默认收藏夹的名字。第一次打开收藏页时自动建一个,保证界面有可选的收藏夹 */
|
|
12
|
+
const DEFAULT_FOLDER_NAME = "本地收藏1";
|
|
13
|
+
function toItem(row) {
|
|
14
|
+
return {
|
|
15
|
+
gid: row.gid,
|
|
16
|
+
token: row.token,
|
|
17
|
+
title: row.title,
|
|
18
|
+
thumbUrl: row.thumb_url,
|
|
19
|
+
pageCount: row.page_count,
|
|
20
|
+
slot: row.slot,
|
|
21
|
+
addedAt: row.added_at,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function createFavoriteService(ctx, gallery, options = {}) {
|
|
25
|
+
const log = options.logger ?? (() => undefined);
|
|
26
|
+
const db = ctx.db.raw;
|
|
27
|
+
/** 上游请求参数:登录后用当前账号的 Cookie */
|
|
28
|
+
function requestOptions(signal) {
|
|
29
|
+
const account = activeAccount(ctx.auth.get());
|
|
30
|
+
const site = account?.site ?? ctx.user.get().preferredSite;
|
|
31
|
+
if (account === null) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const cookies = {
|
|
35
|
+
ipb_member_id: account.cookies.ipbMemberId,
|
|
36
|
+
ipb_pass_hash: account.cookies.ipbPassHash,
|
|
37
|
+
};
|
|
38
|
+
if (account.cookies.igneous !== "") {
|
|
39
|
+
cookies["igneous"] = account.cookies.igneous;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
site,
|
|
43
|
+
cookies,
|
|
44
|
+
...(signal === undefined ? {} : { signal }),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function folders() {
|
|
48
|
+
const rows = db
|
|
49
|
+
.prepare("select * from favorite_folders order by position asc, created_at asc")
|
|
50
|
+
.all();
|
|
51
|
+
return rows.map((row) => ({
|
|
52
|
+
id: row.id,
|
|
53
|
+
name: row.name,
|
|
54
|
+
count: db
|
|
55
|
+
.prepare("select count(*) as n from favorite_items where folder_id = ?")
|
|
56
|
+
.get(row.id).n,
|
|
57
|
+
createdAt: row.created_at,
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
/** 一个夹都没有时建一个默认夹,保证界面始终有可选的收藏夹 */
|
|
61
|
+
function ensureDefaultFolder() {
|
|
62
|
+
const row = db.prepare("select count(*) as n from favorite_folders").get();
|
|
63
|
+
if (row.n > 0) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const now = Math.floor(Date.now() / 1000);
|
|
67
|
+
db.prepare("insert into favorite_folders (id, name, position, created_at) values (?, ?, ?, ?)").run(randomUUID(), DEFAULT_FOLDER_NAME, 1, now);
|
|
68
|
+
}
|
|
69
|
+
function items(folderId) {
|
|
70
|
+
const rows = db
|
|
71
|
+
.prepare("select * from favorite_items where folder_id = ? order by added_at desc")
|
|
72
|
+
.all(folderId);
|
|
73
|
+
return rows.map(toItem);
|
|
74
|
+
}
|
|
75
|
+
async function cloudFolders() {
|
|
76
|
+
const request = requestOptions();
|
|
77
|
+
if (request === null) {
|
|
78
|
+
return { cloud: [], error: "未登录,云端收藏夹不可见" };
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const list = await fetchFavoriteCategories(request);
|
|
82
|
+
return { cloud: [...list], error: null };
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
const reason = error instanceof LoginRequiredError ? "登录已失效" : describeError(error);
|
|
86
|
+
log("warn", `云端收藏夹读取失败:${reason}`);
|
|
87
|
+
return { cloud: [], error: reason };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
async state() {
|
|
92
|
+
ensureDefaultFolder();
|
|
93
|
+
const { cloud, error } = await cloudFolders();
|
|
94
|
+
return { folders: folders(), cloud, cloudError: error };
|
|
95
|
+
},
|
|
96
|
+
async cloudItems(slot) {
|
|
97
|
+
const request = requestOptions();
|
|
98
|
+
if (request === null) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
const pairs = await fetchFavoriteGalleryPairs(slot, request);
|
|
102
|
+
if (pairs.length === 0) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
return gallery.summariesOf(pairs);
|
|
106
|
+
},
|
|
107
|
+
createFolder(name) {
|
|
108
|
+
const now = Math.floor(Date.now() / 1000);
|
|
109
|
+
const next = db
|
|
110
|
+
.prepare("select coalesce(max(position), 0) + 1 as position from favorite_folders")
|
|
111
|
+
.get();
|
|
112
|
+
db.prepare("insert into favorite_folders (id, name, position, created_at) values (?, ?, ?, ?)").run(randomUUID(), name.trim() === "" ? "新收藏夹" : name.trim(), next.position, now);
|
|
113
|
+
return { folders: folders(), cloud: [], cloudError: null };
|
|
114
|
+
},
|
|
115
|
+
removeFolder(id) {
|
|
116
|
+
db.prepare("delete from favorite_items where folder_id = ?").run(id);
|
|
117
|
+
db.prepare("delete from favorite_folders where id = ?").run(id);
|
|
118
|
+
ensureDefaultFolder();
|
|
119
|
+
return { folders: folders(), cloud: [], cloudError: null };
|
|
120
|
+
},
|
|
121
|
+
items,
|
|
122
|
+
async add(input) {
|
|
123
|
+
const now = Math.floor(Date.now() / 1000);
|
|
124
|
+
db.prepare(`insert into favorite_items
|
|
125
|
+
(folder_id, gid, token, title, thumb_url, page_count, slot, added_at)
|
|
126
|
+
values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
127
|
+
on conflict(folder_id, gid) do update set
|
|
128
|
+
title = excluded.title, thumb_url = excluded.thumb_url,
|
|
129
|
+
page_count = excluded.page_count, token = excluded.token`).run(input.folderId, input.gid, input.token, input.title, input.thumbUrl, input.pageCount, input.slot ?? -1, now);
|
|
130
|
+
// 指定了槽位时同步到上游;失败只记日志,本地记录保留
|
|
131
|
+
if (input.slot !== undefined && input.slot >= 0) {
|
|
132
|
+
await syncSlot(input.gid, input.token, input.slot);
|
|
133
|
+
}
|
|
134
|
+
return items(input.folderId);
|
|
135
|
+
},
|
|
136
|
+
removeItem(folderId, gid) {
|
|
137
|
+
db.prepare("delete from favorite_items where folder_id = ? and gid = ?").run(folderId, gid);
|
|
138
|
+
return items(folderId);
|
|
139
|
+
},
|
|
140
|
+
async setSlot(folderId, gid, slot) {
|
|
141
|
+
db.prepare("update favorite_items set slot = ? where folder_id = ? and gid = ?").run(slot, folderId, gid);
|
|
142
|
+
const row = db
|
|
143
|
+
.prepare("select token from favorite_items where folder_id = ? and gid = ?")
|
|
144
|
+
.get(folderId, gid);
|
|
145
|
+
if (row !== undefined) {
|
|
146
|
+
await syncSlot(gid, row.token, slot);
|
|
147
|
+
}
|
|
148
|
+
return items(folderId);
|
|
149
|
+
},
|
|
150
|
+
async refresh(folderId, gids) {
|
|
151
|
+
const all = items(folderId);
|
|
152
|
+
const wanted = gids === undefined || gids.length === 0
|
|
153
|
+
? all
|
|
154
|
+
: all.filter((item) => gids.includes(item.gid));
|
|
155
|
+
const results = [];
|
|
156
|
+
for (const item of wanted) {
|
|
157
|
+
results.push(await moveToLatest(folderId, item));
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
entries: results,
|
|
161
|
+
moved: results.filter((entry) => entry.moved).length,
|
|
162
|
+
items: items(folderId),
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* 把一条收藏挪到它所属画廊的最新版本
|
|
168
|
+
* 上游的处理是「先取消旧版本的收藏,再把新版本放进同一个槽位」;
|
|
169
|
+
* 本地则是把这一行连同 added_at 一起改成新版本(新版本若已在夹里,两行合成一行)
|
|
170
|
+
*/
|
|
171
|
+
async function moveToLatest(folderId, item) {
|
|
172
|
+
const skip = (reason, latestGid = null) => ({
|
|
173
|
+
gid: item.gid,
|
|
174
|
+
latestGid,
|
|
175
|
+
moved: false,
|
|
176
|
+
reason,
|
|
177
|
+
});
|
|
178
|
+
let latestGid;
|
|
179
|
+
let latestToken;
|
|
180
|
+
let latestTitle;
|
|
181
|
+
let latestThumb;
|
|
182
|
+
let latestPages;
|
|
183
|
+
try {
|
|
184
|
+
// 更新标记号同样需要新数据:current 关系变化时需要迁移
|
|
185
|
+
const detail = await gallery.detail(item.gid, item.token, { fresh: true });
|
|
186
|
+
// current 指回来的就是自己时同样视为没有新版本,否则会多一轮无用的上游写入
|
|
187
|
+
if (detail.current === null || detail.current.gid === item.gid) {
|
|
188
|
+
return skip("已是最新版本");
|
|
189
|
+
}
|
|
190
|
+
latestGid = detail.current.gid;
|
|
191
|
+
latestToken = detail.current.token;
|
|
192
|
+
// 新版本的标题与封面一并取回:收藏页显示的内容应与新版本一致
|
|
193
|
+
const summary = await gallery.summaryOf(latestGid, latestToken);
|
|
194
|
+
latestTitle = summary.title;
|
|
195
|
+
latestThumb = summary.thumbUrl;
|
|
196
|
+
latestPages = summary.pageCount;
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
return skip(`读取画廊信息失败:${describeError(error)}`);
|
|
200
|
+
}
|
|
201
|
+
// 上游侧:仅在条目本就在云端收藏中时才操作,两次调用都成功才算同步成功
|
|
202
|
+
let reason = null;
|
|
203
|
+
if (item.slot >= 0) {
|
|
204
|
+
const removed = await syncSlot(item.gid, item.token, -1);
|
|
205
|
+
const added = await syncSlot(latestGid, latestToken, item.slot);
|
|
206
|
+
if (!removed || !added) {
|
|
207
|
+
reason = "上游标记号同步失败,只改了本地";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const targetSlot = item.slot >= 0 ? item.slot : -1;
|
|
211
|
+
db.prepare("delete from favorite_items where folder_id = ? and gid = ?").run(folderId, item.gid);
|
|
212
|
+
db.prepare(`insert into favorite_items
|
|
213
|
+
(folder_id, gid, token, title, thumb_url, page_count, slot, added_at)
|
|
214
|
+
values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
215
|
+
on conflict(folder_id, gid) do update set
|
|
216
|
+
title = excluded.title, thumb_url = excluded.thumb_url,
|
|
217
|
+
page_count = excluded.page_count, token = excluded.token, slot = excluded.slot`).run(folderId, latestGid, latestToken, latestTitle === "" ? item.title : latestTitle, latestThumb === "" ? item.thumbUrl : latestThumb, latestPages, targetSlot, item.addedAt);
|
|
218
|
+
log("info", `更新标记号:#${item.gid} → #${latestGid}${reason === null ? "" : `(${reason})`}`);
|
|
219
|
+
return { gid: item.gid, latestGid, moved: true, reason };
|
|
220
|
+
}
|
|
221
|
+
/** 把标记号同步到上游。未登录或上游不可达都只记日志:本地标记号仍然有效 */
|
|
222
|
+
async function syncSlot(gid, token, slot) {
|
|
223
|
+
const request = requestOptions();
|
|
224
|
+
if (request === null) {
|
|
225
|
+
log("info", `未登录,标记号只改本地:#${gid} → ${slot}`);
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
await setFavorite(gid, token, slot, request);
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
log("warn", `标记号同步失败:#${gid} ${describeError(error)}`);
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 画廊服务:把上游接口元数据与页面解析结果归一化为线上 DTO
|
|
3
|
+
* 列表只从搜索页取 (gid, token),其余字段由 gdata 批量补全;详情与图片页需要解析 HTML
|
|
4
|
+
* 画廊页解析结果带短时缓存,连续翻页时不会每次重新获取
|
|
5
|
+
*/
|
|
6
|
+
import { GALLERY_CATEGORIES, GALLERY_LANGUAGES, SEARCH_PAGE_LIMIT, } from "../api/index.js";
|
|
7
|
+
import { activeAccount } from "../config/index.js";
|
|
8
|
+
import { callApi, fetchArchiveCatalog, galleryUrl, imagePageUrl, isUsablePage, parseGalleryPage, parseImagePage, parseSearchResults, searchUrl, torrentsUrl, LoginRequiredError, } from "../eh/index.js";
|
|
9
|
+
import { createDetailCache } from "./detail-cache.js";
|
|
10
|
+
import { createSearchCache } from "./search-cache.js";
|
|
11
|
+
const SEARCH_PAGE_SIZE = 25;
|
|
12
|
+
/** 查过且当时没有新版本时,隔多久重查一次(秒) */
|
|
13
|
+
const RECHECK_NEWER_SECONDS = 86_400;
|
|
14
|
+
const THUMBS_PER_PAGE = 20;
|
|
15
|
+
/** 界面读取缓存时等待预热的上限。预热卡住时界面不能一直等下去 */
|
|
16
|
+
const WARMUP_WAIT_MS = 12_000;
|
|
17
|
+
/** 定时器不阻塞事件循环,进程可以正常退出 */
|
|
18
|
+
function delay(ms) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
setTimeout(resolve, ms).unref();
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export function createGalleryService(ctx, upstream, options = {}) {
|
|
24
|
+
const cacheSize = options.cacheSize ?? 32;
|
|
25
|
+
const cacheTtlMs = options.cacheTtlMs ?? 5 * 60 * 1000;
|
|
26
|
+
/** 详情页缓存:条数上限取自设置,修改后立即生效 */
|
|
27
|
+
const detailCache = createDetailCache({
|
|
28
|
+
limit: () => ctx.user.get().ui.cachedGalleries,
|
|
29
|
+
...(options.logger === undefined ? {} : { logger: options.logger }),
|
|
30
|
+
});
|
|
31
|
+
const pageCache = new Map();
|
|
32
|
+
const searchCache = createSearchCache(ctx.paths.cacheDir);
|
|
33
|
+
/** 在途的预热检索。界面在没有缓存时会等它,避免同一份结果被拉取两次 */
|
|
34
|
+
let warming = null;
|
|
35
|
+
// 上次运行留下的缓存一律丢弃:重启后的第一份结果由预热检索(或界面自身的检索)决定
|
|
36
|
+
void searchCache.discard();
|
|
37
|
+
/** 记录本次检索,界面再次进入搜索页时可以先据此铺出内容 */
|
|
38
|
+
function rememberSearch(query, result) {
|
|
39
|
+
searchCache.save({
|
|
40
|
+
query: { ...query, page: result.page, limit: result.limit },
|
|
41
|
+
result,
|
|
42
|
+
savedAt: Math.floor(Date.now() / 1000),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
async function runSearch(query) {
|
|
46
|
+
const page = query.page ?? 1;
|
|
47
|
+
const limit = query.limit ?? SEARCH_PAGE_SIZE;
|
|
48
|
+
const response = await callApi({
|
|
49
|
+
url: searchUrl(currentSite(), {
|
|
50
|
+
...query,
|
|
51
|
+
page: Math.max(0, page - 1),
|
|
52
|
+
}),
|
|
53
|
+
...pageOptions(),
|
|
54
|
+
});
|
|
55
|
+
if (!isUsablePage(response.status)) {
|
|
56
|
+
throw new Error(`搜索页返回 HTTP ${response.status}`);
|
|
57
|
+
}
|
|
58
|
+
const pairs = parseSearchResults(response.text).slice(0, limit);
|
|
59
|
+
if (pairs.length === 0) {
|
|
60
|
+
const empty = { items: [], page, limit, hasNext: false };
|
|
61
|
+
rememberSearch(query, empty);
|
|
62
|
+
return empty;
|
|
63
|
+
}
|
|
64
|
+
const infos = await upstream.gdata(pairs.map((pair) => [pair.gid, pair.token]));
|
|
65
|
+
const items = infos.filter((info) => info.error === undefined).map(toSummary);
|
|
66
|
+
const result = {
|
|
67
|
+
items,
|
|
68
|
+
page,
|
|
69
|
+
limit,
|
|
70
|
+
// 上游不返回总数,取满一页即认为可能还有下一页
|
|
71
|
+
hasNext: pairs.length >= limit,
|
|
72
|
+
};
|
|
73
|
+
rememberSearch(query, result);
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
function currentSite() {
|
|
77
|
+
return activeAccount(ctx.auth.get())?.site ?? ctx.user.get().preferredSite;
|
|
78
|
+
}
|
|
79
|
+
function pageOptions(extra = {}) {
|
|
80
|
+
const auth = upstream.callOptions();
|
|
81
|
+
return { ...(auth ?? {}), site: extra.site ?? auth?.site ?? currentSite() };
|
|
82
|
+
}
|
|
83
|
+
async function loadGalleryPage(gid, token, thumbPage = 0, signal) {
|
|
84
|
+
const key = `${gid}:${token}:${thumbPage}`;
|
|
85
|
+
const hit = pageCache.get(key);
|
|
86
|
+
if (hit !== undefined && Date.now() - hit.at < cacheTtlMs) {
|
|
87
|
+
return hit.page;
|
|
88
|
+
}
|
|
89
|
+
const response = await callApi({
|
|
90
|
+
url: galleryUrl(currentSite(), gid, token, thumbPage),
|
|
91
|
+
...pageOptions(),
|
|
92
|
+
...(signal === undefined ? {} : { signal }),
|
|
93
|
+
});
|
|
94
|
+
if (!isUsablePage(response.status)) {
|
|
95
|
+
throw new Error(`画廊页返回 HTTP ${response.status}`);
|
|
96
|
+
}
|
|
97
|
+
const parsed = parseGalleryPage(response.text, gid);
|
|
98
|
+
if (parsed.title === "" && parsed.previewPages.length === 0) {
|
|
99
|
+
throw new Error("画廊页解析为空,页面结构可能已变更");
|
|
100
|
+
}
|
|
101
|
+
pageCache.set(key, { at: Date.now(), page: parsed });
|
|
102
|
+
if (pageCache.size > cacheSize) {
|
|
103
|
+
const oldest = pageCache.keys().next().value;
|
|
104
|
+
if (oldest !== undefined) {
|
|
105
|
+
pageCache.delete(oldest);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return parsed;
|
|
109
|
+
}
|
|
110
|
+
async function infoOf(gid, token) {
|
|
111
|
+
const infos = await upstream.gdata([[gid, token]]);
|
|
112
|
+
const info = infos[0];
|
|
113
|
+
if (info === undefined) {
|
|
114
|
+
throw new Error("gdata 未返回该画廊");
|
|
115
|
+
}
|
|
116
|
+
if (info.error !== undefined) {
|
|
117
|
+
throw new Error(`gdata 拒绝:${info.error}`);
|
|
118
|
+
}
|
|
119
|
+
return info;
|
|
120
|
+
}
|
|
121
|
+
/** 解析某一页的图片地址。第 1 页(详情页封面)由详情缓存包住,其余页直接调用 */
|
|
122
|
+
async function loadImagePage(gid, token, page, pageToken) {
|
|
123
|
+
let resolved = pageToken;
|
|
124
|
+
if (resolved === undefined) {
|
|
125
|
+
const thumbPage = Math.floor((Math.max(1, page) - 1) / THUMBS_PER_PAGE);
|
|
126
|
+
const parsed = await loadGalleryPage(gid, token, thumbPage);
|
|
127
|
+
resolved = parsed.pageTokens.get(page);
|
|
128
|
+
}
|
|
129
|
+
if (resolved === undefined) {
|
|
130
|
+
throw new Error(`未找到第 ${page} 页的图片页 token`);
|
|
131
|
+
}
|
|
132
|
+
const response = await callApi({
|
|
133
|
+
url: imagePageUrl(currentSite(), gid, page, resolved),
|
|
134
|
+
...pageOptions(),
|
|
135
|
+
});
|
|
136
|
+
if (!isUsablePage(response.status)) {
|
|
137
|
+
throw new Error(`图片页返回 HTTP ${response.status}`);
|
|
138
|
+
}
|
|
139
|
+
const parsed = parseImagePage(response.text, gid);
|
|
140
|
+
if (parsed.showkey === null) {
|
|
141
|
+
throw new Error("图片页未取到 showkey");
|
|
142
|
+
}
|
|
143
|
+
const result = await upstream.showpage({
|
|
144
|
+
gid,
|
|
145
|
+
page,
|
|
146
|
+
imgkey: resolved,
|
|
147
|
+
showkey: parsed.showkey,
|
|
148
|
+
});
|
|
149
|
+
return {
|
|
150
|
+
page,
|
|
151
|
+
pageToken: resolved,
|
|
152
|
+
imageUrl: result.imageUrl,
|
|
153
|
+
originalImageUrl: result.originalImageUrl,
|
|
154
|
+
skipHathKey: result.skipHathKey,
|
|
155
|
+
// 上游不再返回尺寸,保留字段
|
|
156
|
+
width: null,
|
|
157
|
+
height: null,
|
|
158
|
+
nextPageToken: parsed.nextToken,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
search: runSearch,
|
|
163
|
+
async cachedSearch() {
|
|
164
|
+
const hit = await searchCache.read();
|
|
165
|
+
if (hit !== null) {
|
|
166
|
+
return hit;
|
|
167
|
+
}
|
|
168
|
+
if (warming === null) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
// 预热正在进行:等待它结束,界面不必自己再发起一次同样的检索。
|
|
172
|
+
// 但只等待 WARMUP_WAIT_MS,预热卡住(例如上游连不上)时界面不会一直等下去
|
|
173
|
+
await Promise.race([warming, delay(WARMUP_WAIT_MS)]);
|
|
174
|
+
return searchCache.read();
|
|
175
|
+
},
|
|
176
|
+
async warmSearch() {
|
|
177
|
+
if (warming !== null) {
|
|
178
|
+
return warming;
|
|
179
|
+
}
|
|
180
|
+
warming = (async () => {
|
|
181
|
+
try {
|
|
182
|
+
await runSearch({ page: 1, limit: SEARCH_PAGE_LIMIT });
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
options.logger?.("warn", `启动预热检索失败:${error instanceof Error ? error.message : String(error)}`);
|
|
186
|
+
}
|
|
187
|
+
})();
|
|
188
|
+
return warming;
|
|
189
|
+
},
|
|
190
|
+
async summaryOf(gid, token, signal) {
|
|
191
|
+
const infos = await upstream.gdata([[gid, token]], {
|
|
192
|
+
...(signal === undefined ? {} : { signal }),
|
|
193
|
+
});
|
|
194
|
+
const info = infos[0];
|
|
195
|
+
if (info === undefined || info.error !== undefined) {
|
|
196
|
+
throw new Error(`画廊信息读取失败:#${gid}`);
|
|
197
|
+
}
|
|
198
|
+
return toSummary(info);
|
|
199
|
+
},
|
|
200
|
+
async summariesOf(pairs, signal) {
|
|
201
|
+
if (pairs.length === 0) {
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
const infos = await upstream.gdata(pairs.map(([gid, token]) => [gid, token]), signal === undefined ? {} : { signal });
|
|
205
|
+
return infos.filter((info) => info.error === undefined).map(toSummary);
|
|
206
|
+
},
|
|
207
|
+
detail(gid, token, detailOptions) {
|
|
208
|
+
return detailCache.detail(gid, token, async () => {
|
|
209
|
+
// 内存未命中时读取落盘副本,fresh 的调用方不经过这里
|
|
210
|
+
const stored = options.store === undefined ? null : await options.store.get(gid, token);
|
|
211
|
+
if (stored !== null) {
|
|
212
|
+
return stored.detail;
|
|
213
|
+
}
|
|
214
|
+
const [info, parsed] = await Promise.all([
|
|
215
|
+
infoOf(gid, token),
|
|
216
|
+
loadGalleryPage(gid, token),
|
|
217
|
+
]);
|
|
218
|
+
const detail = toDetail(info, parsed);
|
|
219
|
+
// 落盘,重启后同一画廊可以立即打开
|
|
220
|
+
await options.store?.put(gid, token, detail);
|
|
221
|
+
return detail;
|
|
222
|
+
}, detailOptions?.fresh === true);
|
|
223
|
+
},
|
|
224
|
+
async newer(gid, token) {
|
|
225
|
+
const store = options.store;
|
|
226
|
+
const stored = store === undefined ? null : await store.get(gid, token);
|
|
227
|
+
if (stored !== null && stored.newer !== null) {
|
|
228
|
+
return { newer: stored.newer, checkedAt: stored.checkedAt };
|
|
229
|
+
}
|
|
230
|
+
const checkedAt = stored?.checkedAt ?? null;
|
|
231
|
+
if (checkedAt !== null && Date.now() / 1000 - checkedAt < RECHECK_NEWER_SECONDS) {
|
|
232
|
+
return { newer: null, checkedAt };
|
|
233
|
+
}
|
|
234
|
+
const info = await infoOf(gid, token);
|
|
235
|
+
const newer = relationOfNewer(info, gid, token);
|
|
236
|
+
await store?.saveCheck(gid, token, newer);
|
|
237
|
+
return { newer, checkedAt: Math.floor(Date.now() / 1000) };
|
|
238
|
+
},
|
|
239
|
+
imagePage(gid, token, page, pageToken, pageOptions) {
|
|
240
|
+
// 第 1 页即详情页封面,因此纳入缓存(有效期 10 分钟);其余页地址过期风险更大,直接透传
|
|
241
|
+
if (page !== 1 || pageToken !== undefined) {
|
|
242
|
+
return loadImagePage(gid, token, page, pageToken);
|
|
243
|
+
}
|
|
244
|
+
return detailCache.cover(gid, token, () => loadImagePage(gid, token, page, pageToken), pageOptions?.fresh === true);
|
|
245
|
+
},
|
|
246
|
+
async torrents(gid, token) {
|
|
247
|
+
const info = await infoOf(gid, token);
|
|
248
|
+
const site = currentSite();
|
|
249
|
+
return (info.torrents ?? []).map((torrent) => ({
|
|
250
|
+
hash: torrent.hash,
|
|
251
|
+
name: torrent.name,
|
|
252
|
+
// 单条种子的直链需要登录,此处给列表页地址
|
|
253
|
+
url: torrentsUrl(site, gid, token),
|
|
254
|
+
addedAt: Number(torrent.added),
|
|
255
|
+
sizeBytes: Number(torrent.fsize),
|
|
256
|
+
}));
|
|
257
|
+
},
|
|
258
|
+
previews(gid, token, index, signal, previewOptions) {
|
|
259
|
+
const target = Math.max(0, index);
|
|
260
|
+
return detailCache.previews(gid, token, target, async () => {
|
|
261
|
+
const parsed = await loadGalleryPage(gid, token, target, signal);
|
|
262
|
+
const previews = parsed.previewPages.map((page) => ({
|
|
263
|
+
...page,
|
|
264
|
+
}));
|
|
265
|
+
return {
|
|
266
|
+
index: target,
|
|
267
|
+
previews,
|
|
268
|
+
hasMore: previews.length >= THUMBS_PER_PAGE,
|
|
269
|
+
};
|
|
270
|
+
}, previewOptions?.fresh === true);
|
|
271
|
+
},
|
|
272
|
+
detailCacheStats() {
|
|
273
|
+
return detailCache.stats();
|
|
274
|
+
},
|
|
275
|
+
clearDetailCache() {
|
|
276
|
+
return detailCache.clear();
|
|
277
|
+
},
|
|
278
|
+
async archives(gid, token) {
|
|
279
|
+
if (!upstream.hasAccount()) {
|
|
280
|
+
throw new LoginRequiredError("归档列表需要登录后查看");
|
|
281
|
+
}
|
|
282
|
+
const parsed = await fetchArchiveCatalog(gid, token, {
|
|
283
|
+
site: currentSite(),
|
|
284
|
+
...(pageOptions().cookies === undefined ? {} : { cookies: pageOptions().cookies }),
|
|
285
|
+
});
|
|
286
|
+
const options_ = parsed.options.map((option) => ({
|
|
287
|
+
resolution: option.resolution,
|
|
288
|
+
label: option.label,
|
|
289
|
+
sizeText: option.sizeText,
|
|
290
|
+
costText: option.costText,
|
|
291
|
+
viaHath: option.viaHath,
|
|
292
|
+
}));
|
|
293
|
+
const catalog = { options: options_, fundsText: parsed.fundsText };
|
|
294
|
+
return catalog;
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
const CATEGORY_SET = new Set(GALLERY_CATEGORIES);
|
|
299
|
+
const LANGUAGE_SET = new Set(GALLERY_LANGUAGES);
|
|
300
|
+
/** 上游给的是字符串,取不到或不在枚举内时归入 Misc */
|
|
301
|
+
function toCategory(value) {
|
|
302
|
+
const raw = (value ?? "").trim();
|
|
303
|
+
return (CATEGORY_SET.has(raw) ? raw : "Misc");
|
|
304
|
+
}
|
|
305
|
+
function toLanguage(tags) {
|
|
306
|
+
const found = (tags ?? []).find((tag) => tag.startsWith("language:"));
|
|
307
|
+
if (found === undefined) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
const value = found.slice("language:".length);
|
|
311
|
+
return LANGUAGE_SET.has(value) ? value : null;
|
|
312
|
+
}
|
|
313
|
+
/** 上游把数字当字符串返回,这里统一转换 */
|
|
314
|
+
function toNumber(value) {
|
|
315
|
+
if (typeof value === "number") {
|
|
316
|
+
return Number.isFinite(value) ? value : 0;
|
|
317
|
+
}
|
|
318
|
+
const parsed = Number(value ?? "");
|
|
319
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
320
|
+
}
|
|
321
|
+
function toSummary(info) {
|
|
322
|
+
return {
|
|
323
|
+
gid: info.gid,
|
|
324
|
+
token: info.token ?? "",
|
|
325
|
+
title: info.title ?? "",
|
|
326
|
+
titleJpn: info.title_jpn ?? "",
|
|
327
|
+
category: toCategory(info.category),
|
|
328
|
+
thumbUrl: info.thumb ?? "",
|
|
329
|
+
uploader: info.uploader ?? "",
|
|
330
|
+
postedAt: toNumber(info.posted),
|
|
331
|
+
pageCount: toNumber(info.filecount),
|
|
332
|
+
sizeBytes: info.filesize ?? 0,
|
|
333
|
+
rating: toNumber(info.rating),
|
|
334
|
+
torrentCount: toNumber(info.torrentcount),
|
|
335
|
+
expunged: info.expunged === true,
|
|
336
|
+
tags: info.tags ?? [],
|
|
337
|
+
language: toLanguage(info.tags),
|
|
338
|
+
// 收藏状态需要登录后另查
|
|
339
|
+
favorite: null,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
/** 标签组优先用页面解析结果(含显示名),缺失时退回 gdata 的带前缀标签 */
|
|
343
|
+
function toTagGroups(info, parsed) {
|
|
344
|
+
if (parsed.tagGroups.length > 0) {
|
|
345
|
+
return parsed.tagGroups.map((group) => ({ namespace: group.namespace, tags: group.tags }));
|
|
346
|
+
}
|
|
347
|
+
const grouped = new Map();
|
|
348
|
+
for (const tag of info.tags ?? []) {
|
|
349
|
+
const separator = tag.indexOf(":");
|
|
350
|
+
if (separator === -1) {
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
const namespace = tag.slice(0, separator);
|
|
354
|
+
const bucket = grouped.get(namespace) ?? [];
|
|
355
|
+
bucket.push(tag.slice(separator + 1));
|
|
356
|
+
grouped.set(namespace, bucket);
|
|
357
|
+
}
|
|
358
|
+
return [...grouped].map(([namespace, tags]) => ({ namespace, tags }));
|
|
359
|
+
}
|
|
360
|
+
function toRelation(gid, key) {
|
|
361
|
+
const parsed = Number(gid ?? "");
|
|
362
|
+
return Number.isInteger(parsed) && parsed > 0 && key !== undefined
|
|
363
|
+
? { gid: parsed, token: key }
|
|
364
|
+
: null;
|
|
365
|
+
}
|
|
366
|
+
/*
|
|
367
|
+
* gdata 返回的 current_* 与自身不同,表示上游存在更新版本。
|
|
368
|
+
* 未返回、与自身相同或字段不完整时,按已是最新处理。
|
|
369
|
+
*/
|
|
370
|
+
function relationOfNewer(info, gid, token) {
|
|
371
|
+
const targetGid = Number(info.current_gid ?? "");
|
|
372
|
+
const targetToken = info.current_key ?? "";
|
|
373
|
+
if (!Number.isFinite(targetGid) || targetGid <= 0 || targetToken === "") {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
if (targetGid === gid && targetToken === token) {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
return { gid: targetGid, token: targetToken };
|
|
380
|
+
}
|
|
381
|
+
function toDetail(info, parsed) {
|
|
382
|
+
const summary = toSummary(info);
|
|
383
|
+
return {
|
|
384
|
+
...summary,
|
|
385
|
+
title: parsed.title === "" ? summary.title : parsed.title,
|
|
386
|
+
titleJpn: parsed.titleJpn === "" ? summary.titleJpn : parsed.titleJpn,
|
|
387
|
+
category: parsed.category === "" ? summary.category : toCategory(parsed.category),
|
|
388
|
+
tagGroups: toTagGroups(info, parsed),
|
|
389
|
+
// 上游页面不直接给可见性文本,以 expunged 推导
|
|
390
|
+
visibility: info.expunged === true ? "Deleted" : "Visible",
|
|
391
|
+
sizeText: parsed.sizeText === "" ? `${summary.sizeBytes} B` : parsed.sizeText,
|
|
392
|
+
rating: parsed.rating ?? summary.rating,
|
|
393
|
+
ratingCount: parsed.ratingCount,
|
|
394
|
+
favoriteCount: parsed.favoriteCount,
|
|
395
|
+
previewPages: parsed.previewPages.map((page) => ({ ...page })),
|
|
396
|
+
// 评论区结构复杂,暂不解析
|
|
397
|
+
comments: [],
|
|
398
|
+
archiveAvailable: parsed.archiveAvailable,
|
|
399
|
+
parent: toRelation(info.parent_gid, info.parent_key),
|
|
400
|
+
first: toRelation(info.first_gid, info.first_key),
|
|
401
|
+
current: toRelation(info.current_gid, info.current_key),
|
|
402
|
+
};
|
|
403
|
+
}
|