koishi-plugin-fimtale-api 0.0.1 → 0.0.3

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/lib/index.d.ts CHANGED
@@ -17,20 +17,12 @@ export interface Config {
17
17
  apiUrl: string;
18
18
  apiKey: string;
19
19
  apiPass: string;
20
+ cookies: string;
20
21
  pollInterval: number;
21
22
  autoParseLink: boolean;
22
- showPreview: boolean;
23
- previewLength: number;
24
- readTheme: 'light' | 'dark' | 'sepia';
25
- messages: {
26
- subSuccess: string;
27
- subExists: string;
28
- unsubSuccess: string;
29
- notFound: string;
30
- updateAlert: string;
31
- infoTemplate: string;
32
- errorApi: string;
33
- };
23
+ deviceWidth: number;
24
+ deviceHeight: number;
25
+ fontSize: number;
34
26
  }
35
27
  export declare const Config: Schema<Config>;
36
28
  export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js CHANGED
@@ -1,6 +1,8 @@
1
+ var __create = Object.create;
1
2
  var __defProp = Object.defineProperty;
2
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __getProtoOf = Object.getPrototypeOf;
4
6
  var __hasOwnProp = Object.prototype.hasOwnProperty;
5
7
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
6
8
  var __export = (target, all) => {
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.ts
@@ -27,30 +37,19 @@ __export(src_exports, {
27
37
  });
28
38
  module.exports = __toCommonJS(src_exports);
29
39
  var import_koishi = require("koishi");
40
+ var import_crypto = __toESM(require("crypto"));
30
41
  var name = "fimtale-watcher";
31
42
  var inject = ["puppeteer", "database", "http"];
32
43
  var Config = import_koishi.Schema.object({
33
44
  apiUrl: import_koishi.Schema.string().default("https://fimtale.com/api/v1").description("Fimtale API 基础路径"),
34
45
  apiKey: import_koishi.Schema.string().role("secret").required().description("API Key (必填)"),
35
46
  apiPass: import_koishi.Schema.string().role("secret").required().description("API Pass (必填)"),
36
- pollInterval: import_koishi.Schema.number().default(60 * 1e3).description("追更轮询间隔(毫秒)"),
37
- autoParseLink: import_koishi.Schema.boolean().default(true).description("是否开启链接自动解析"),
38
- showPreview: import_koishi.Schema.boolean().default(true).description("推送或查询时是否显示文字预览"),
39
- previewLength: import_koishi.Schema.number().default(100).description("预览文本的字数限制"),
40
- readTheme: import_koishi.Schema.union([
41
- import_koishi.Schema.const("light").description("明亮"),
42
- import_koishi.Schema.const("dark").description("暗黑"),
43
- import_koishi.Schema.const("sepia").description("羊皮纸(护眼)")
44
- ]).default("sepia").description("阅读器图片生成的背景主题"),
45
- messages: import_koishi.Schema.object({
46
- subSuccess: import_koishi.Schema.string().default("✅ 订阅成功!\n📖 标题:{title}\n✍️ 作者:{author}\n当前 {count} 个回复"),
47
- subExists: import_koishi.Schema.string().default("⚠️ 你已经订阅过这个帖子了。"),
48
- unsubSuccess: import_koishi.Schema.string().default("❎ 已取消订阅帖子:{id}"),
49
- notFound: import_koishi.Schema.string().default("❌ 帖子不存在或 API 请求失败。"),
50
- updateAlert: import_koishi.Schema.string().default("📢 <b>{title}</b> 有新动态!\n\n最新回复:{lastUser}\n回复总数:{count}\n\n传送门:{url}"),
51
- infoTemplate: import_koishi.Schema.string().default("📄 <b>{title}</b>\n作者:{author}\n热度:{views} | 回复:{count}\n最后更新:{time}\n\n{preview}"),
52
- errorApi: import_koishi.Schema.string().default("❌ API 连接失败,请检查配置。")
53
- }).description("自定义提示文案 (支持 {变量} 替换)")
47
+ cookies: import_koishi.Schema.string().role("secret").description("浏览器 Cookie (用于解除安全模式,必填)"),
48
+ pollInterval: import_koishi.Schema.number().default(60 * 1e3).description("追更轮询间隔(ms)"),
49
+ autoParseLink: import_koishi.Schema.boolean().default(true).description("自动解析链接为预览卡片"),
50
+ deviceWidth: import_koishi.Schema.number().default(390).description("阅读器渲染宽度(px)"),
51
+ deviceHeight: import_koishi.Schema.number().default(844).description("阅读器渲染高度(px)"),
52
+ fontSize: import_koishi.Schema.number().default(22).description("正文字号(px)")
54
53
  });
55
54
  function apply(ctx, config) {
56
55
  ctx.model.extend("fimtale_subs", {
@@ -59,235 +58,471 @@ function apply(ctx, config) {
59
58
  threadId: "string",
60
59
  lastCount: "integer",
61
60
  lastCheck: "integer"
62
- }, {
63
- primary: "id",
64
- autoInc: true
65
- });
61
+ }, { primary: "id", autoInc: true });
62
+ const sleep = /* @__PURE__ */ __name((ms) => new Promise((resolve) => setTimeout(resolve, ms)), "sleep");
63
+ const formatDate = /* @__PURE__ */ __name((timestamp) => {
64
+ if (!timestamp) return "Unknown";
65
+ const date = new Date(timestamp * 1e3);
66
+ return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")}`;
67
+ }, "formatDate");
66
68
  const stripHtml = /* @__PURE__ */ __name((html) => {
67
69
  if (!html) return "";
68
70
  return html.replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
69
71
  }, "stripHtml");
70
- const formatTime = /* @__PURE__ */ __name((timestamp) => {
71
- if (!timestamp) return "未知";
72
- return import_koishi.Time.format(timestamp * 1e3);
73
- }, "formatTime");
74
- const formatText = /* @__PURE__ */ __name((template, data) => {
75
- return template.replace(/\{(\w+)\}/g, (_, key) => data[key] ?? "");
76
- }, "formatText");
72
+ const extractImage = /* @__PURE__ */ __name((html) => {
73
+ if (!html) return null;
74
+ const match = html.match(/<img[^>]+src="([^">]+)"/);
75
+ return match ? match[1] : null;
76
+ }, "extractImage");
77
+ const generateGradient = /* @__PURE__ */ __name((str) => {
78
+ const hash = import_crypto.default.createHash("md5").update(str || "default").digest("hex");
79
+ const c1 = "#" + hash.substring(0, 6);
80
+ const c2 = "#" + hash.substring(6, 12);
81
+ return `linear-gradient(135deg, ${c1} 0%, ${c2} 100%)`;
82
+ }, "generateGradient");
83
+ const injectCookies = /* @__PURE__ */ __name(async (page) => {
84
+ if (!config.cookies) return;
85
+ const cookies = config.cookies.split(";").map((pair) => {
86
+ const parts = pair.trim().split("=");
87
+ if (parts.length < 2) return null;
88
+ return { name: parts[0].trim(), value: parts.slice(1).join("=").trim(), domain: "fimtale.com", path: "/" };
89
+ }).filter((c) => c !== null);
90
+ if (cookies.length) await page.setCookie(...cookies);
91
+ }, "injectCookies");
77
92
  const fetchThread = /* @__PURE__ */ __name(async (threadId) => {
78
93
  try {
79
- const endpoint = `${config.apiUrl}/t/${threadId}`;
94
+ const url = `${config.apiUrl}/t/${threadId}`;
80
95
  const params = { APIKey: config.apiKey, APIPass: config.apiPass };
81
- const res = await ctx.http.get(endpoint, { params });
82
- if (!res || res.Status !== 1 || !res.TopicInfo) {
83
- return { valid: false };
84
- }
85
- const info = res.TopicInfo;
96
+ const res = await ctx.http.get(url, { params });
97
+ if (res.Status !== 1 || !res.TopicInfo) return { valid: false, msg: res.ErrorMessage || "API Error" };
86
98
  return {
87
99
  valid: true,
88
- info,
89
- // 原始完整信息
90
- menu: res.Menu || [],
91
- // 目录数组
92
- // 提取常用字段方便使用
93
- id: info.ID,
94
- title: info.Title,
95
- author: info.UserName,
96
- views: info.Views,
97
- comments: info.Comments,
98
- lastTime: formatTime(info.LastTime),
99
- lastUser: info.LastUser,
100
- content: info.Content,
101
- dateCreated: info.DateCreated
100
+ data: res.TopicInfo,
101
+ parent: res.ParentInfo,
102
+ menu: res.Menu || []
102
103
  };
103
104
  } catch (e) {
104
- ctx.logger("fimtale").warn(`Fetch error for ${threadId}: ${e}`);
105
- return { valid: false };
105
+ return { valid: false, msg: "Request Failed" };
106
106
  }
107
107
  }, "fetchThread");
108
- ctx.command("ft.sub <threadId:string>", "订阅帖子追更").action(async ({ session }, threadId) => {
109
- if (!threadId || !/^\d+$/.test(threadId)) return "请输入纯数字的帖子ID。";
110
- const existing = await ctx.database.get("fimtale_subs", { cid: session.cid, threadId });
111
- if (existing.length > 0) return config.messages.subExists;
112
- const data = await fetchThread(threadId);
113
- if (!data.valid) return config.messages.notFound;
114
- await ctx.database.create("fimtale_subs", {
115
- cid: session.cid,
116
- threadId,
117
- lastCount: data.comments,
118
- lastCheck: Date.now()
119
- });
120
- return formatText(config.messages.subSuccess, {
121
- id: threadId,
122
- title: data.title,
123
- author: data.author,
124
- count: data.comments
125
- });
126
- });
127
- ctx.command("ft.unsub <threadId:string>", "取消订阅").action(async ({ session }, threadId) => {
128
- if (!threadId) return "请输入帖子ID。";
129
- const res = await ctx.database.remove("fimtale_subs", { cid: session.cid, threadId });
130
- return res.matched ? formatText(config.messages.unsubSuccess, { id: threadId }) : "未找到对应订阅。";
131
- });
132
- ctx.command("ft.info <threadId:string>", "查询帖子详情").action(async (_, threadId) => {
133
- if (!threadId) return "请输入ID。";
134
- const data = await fetchThread(threadId);
135
- if (!data.valid) return config.messages.notFound;
136
- let preview = "";
137
- if (config.showPreview) {
138
- const rawText = stripHtml(data.content);
139
- preview = rawText.length > config.previewLength ? rawText.substring(0, config.previewLength) + "..." : rawText;
108
+ const fetchRandomId = /* @__PURE__ */ __name(async () => {
109
+ try {
110
+ const headers = config.cookies ? { Cookie: config.cookies } : {};
111
+ const html = await ctx.http.get("https://fimtale.com/rand", { responseType: "text", headers });
112
+ let match = html.match(/FimTale\.topic\.init\((\d+)/) || html.match(/data-clipboard-text=".*?\/t\/(\d+)"/);
113
+ return match ? match[1] : null;
114
+ } catch (e) {
115
+ return null;
140
116
  }
141
- return formatText(config.messages.infoTemplate, {
142
- title: data.title,
143
- author: data.author,
144
- views: data.views,
145
- count: data.comments,
146
- time: data.lastTime,
147
- preview
148
- });
149
- });
150
- ctx.command("ft.read <threadId:string>", "阅读章节(生成长图)").action(async ({ session }, threadId) => {
151
- if (!threadId) return "请输入章节ID。";
152
- const data = await fetchThread(threadId);
153
- if (!data.valid) return "❌ 读取失败,可能是网络问题或帖子不存在。";
154
- const info = data.info;
155
- const menu = data.menu;
156
- let prevId = null;
157
- let nextId = null;
158
- if (menu && menu.length > 0) {
159
- const currentIndex = menu.findIndex((item) => item.ID.toString() === threadId);
160
- if (currentIndex !== -1) {
161
- if (currentIndex > 0) prevId = menu[currentIndex - 1].ID;
162
- if (currentIndex < menu.length - 1) nextId = menu[currentIndex + 1].ID;
117
+ }, "fetchRandomId");
118
+ const searchThreads = /* @__PURE__ */ __name(async (keyword) => {
119
+ let page;
120
+ try {
121
+ page = await ctx.puppeteer.page();
122
+ await page.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36");
123
+ await injectCookies(page);
124
+ const searchUrl = `https://fimtale.com/topics?q=${encodeURIComponent(keyword)}`;
125
+ await page.goto(searchUrl, { waitUntil: "networkidle2", timeout: 25e3 });
126
+ try {
127
+ await page.waitForSelector(".card", { timeout: 5e3 });
128
+ } catch {
163
129
  }
164
- }
165
- const themes = {
166
- light: { bg: "#ffffff", text: "#333333", accent: "#666" },
167
- dark: { bg: "#2b2b2b", text: "#dcdcdc", accent: "#888" },
168
- sepia: { bg: "#f5e8d0", text: "#5b4636", accent: "#8b7355" }
169
- // 羊皮纸
170
- };
171
- const t = themes[config.readTheme];
172
- const htmlContent = `
173
- <!DOCTYPE html>
174
- <html>
175
- <head>
176
- <meta charset="UTF-8">
177
- <style>
178
- body {
179
- font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
180
- background-color: ${t.bg};
181
- color: ${t.text};
182
- padding: 40px;
183
- width: 500px; /* 限制宽度适合手机观看 */
184
- line-height: 1.8;
185
- word-wrap: break-word;
130
+ const results = await page.evaluate(() => {
131
+ const items = [];
132
+ const cards = document.querySelectorAll(".card.topic-card");
133
+ cards.forEach((card) => {
134
+ if (items.length >= 6) return;
135
+ const link = card.querySelector('a[href^="/t/"]');
136
+ const href = link?.getAttribute("href");
137
+ const idMatch = href?.match(/^\/t\/(\d+)$/);
138
+ if (!idMatch) return;
139
+ const id = idMatch[1];
140
+ if (items.some((i) => i.id === id)) return;
141
+ let title = "";
142
+ const titleEl = card.querySelector(".card-title");
143
+ if (titleEl) title = titleEl.textContent?.trim() || "";
144
+ else title = link.textContent?.trim() || "";
145
+ let author = "Unknown";
146
+ const authorEl = card.querySelector('a[href^="/u/"] span.grey-text');
147
+ if (authorEl) author = authorEl.textContent?.trim() || "";
148
+ let cover = void 0;
149
+ const imgEl = card.querySelector(".card-image img");
150
+ if (imgEl) {
151
+ const src = imgEl.getAttribute("src");
152
+ if (src && (!src.includes("avatar") || src.includes("upload"))) cover = src;
186
153
  }
187
- .header { margin-bottom: 25px; border-bottom: 2px solid ${t.accent}; padding-bottom: 15px; }
188
- .title { font-size: 26px; font-weight: bold; margin-bottom: 8px; }
189
- .meta { font-size: 14px; opacity: 0.8; }
190
- .content { font-size: 18px; text-align: justify; }
191
- /* Fimtale 的内容通常包含 p 标签,我们做一些优化 */
192
- .content p { margin-bottom: 1.2em; text-indent: 2em; }
193
- .content img { max-width: 100%; height: auto; border-radius: 6px; display: block; margin: 10px auto; }
194
- .footer { margin-top: 40px; text-align: center; font-size: 12px; opacity: 0.6; }
195
- </style>
196
- </head>
197
- <body>
198
- <div class="header">
199
- <div class="title">${info.Title}</div>
200
- <div class="meta">
201
- 作者:${info.UserName} &nbsp;|&nbsp; ${import_koishi.Time.format(info.DateCreated * 1e3).split(" ")[0]}
154
+ const tags = [];
155
+ let status = "";
156
+ card.querySelectorAll(".main-tag-set div").forEach((b) => {
157
+ const t = b.textContent?.trim();
158
+ if (t) tags.push(t);
159
+ });
160
+ card.querySelectorAll(".chip").forEach((c) => {
161
+ const t = c.textContent?.trim();
162
+ if (!t) return;
163
+ if (["连载中", "已完结", "已弃坑"].includes(t)) status = t;
164
+ else if (!t.includes("展开其余")) tags.push(t);
165
+ });
166
+ const stats = { views: "0", comments: "0", likes: "0", words: "0" };
167
+ const actionDiv = card.querySelector(".card-action > div");
168
+ if (actionDiv) {
169
+ actionDiv.querySelectorAll("span[title]").forEach((s) => {
170
+ const t = s.getAttribute("title") || "";
171
+ const v = s.textContent?.trim().replace(/,/g, "") || "0";
172
+ if (t.includes("字")) stats.words = v;
173
+ if (t.includes("阅读")) stats.views = v;
174
+ if (t.includes("评论")) stats.comments = v;
175
+ });
176
+ }
177
+ const greenText = card.querySelector(".left.green-text");
178
+ if (greenText) stats.likes = greenText.textContent?.replace(/[^0-9]/g, "") || "0";
179
+ let updateTime = "";
180
+ const timeSpan = card.querySelector('div[style*="margin: 3px 0;"] span.grey-text');
181
+ if (timeSpan) {
182
+ const txt = timeSpan.textContent || "";
183
+ const dateMatch = txt.match(/(\d{4}\s*年\s*\d{1,2}\s*月\s*\d{1,2}\s*日)/) || txt.match(/(\d+\s*(?:小时|分钟|天)前)/) || txt.match(/(\d{1,2}\s*月\s*\d{1,2}\s*日)/);
184
+ if (dateMatch) updateTime = dateMatch[1].replace(/\s/g, "");
185
+ }
186
+ items.push({ id, title, author, cover, tags: tags.slice(0, 8), status, stats, updateTime });
187
+ });
188
+ return items;
189
+ });
190
+ return results;
191
+ } catch (e) {
192
+ return [];
193
+ } finally {
194
+ if (page) await page.close();
195
+ }
196
+ }, "searchThreads");
197
+ const renderCard = /* @__PURE__ */ __name(async (info, parent) => {
198
+ const isChapter = info.IsChapter || !!parent && parent.ID !== info.ID;
199
+ const displayTitle = isChapter && parent ? parent.Title : info.Title;
200
+ const displayCover = isChapter && parent ? parent.Background || extractImage(parent.Content) : info.Background || extractImage(info.Content);
201
+ const displayTagsObj = isChapter && parent ? parent.Tags : info.Tags;
202
+ const subTitle = isChapter ? info.Title : null;
203
+ const views = info.Views || 0;
204
+ const comments = info.Comments || 0;
205
+ const words = info.WordCount || 0;
206
+ const likes = info.Upvotes || 0;
207
+ const bgStyle = displayCover ? `background-image: url('${displayCover}');` : `background: ${generateGradient(displayTitle)};`;
208
+ let summary = stripHtml(info.Content);
209
+ if (summary.length < 10 && parent && isChapter) summary = stripHtml(parent.Content);
210
+ if (summary.length > 100) summary = summary.substring(0, 100) + "...";
211
+ if (!summary) summary = "No Introduction";
212
+ const tagsArr = [];
213
+ if (displayTagsObj?.Type) tagsArr.push(displayTagsObj.Type);
214
+ if (displayTagsObj?.Rating && displayTagsObj.Rating !== "E") tagsArr.push(displayTagsObj.Rating);
215
+ if (displayTagsObj?.OtherTags && Array.isArray(displayTagsObj.OtherTags)) {
216
+ tagsArr.push(...displayTagsObj.OtherTags);
217
+ }
218
+ const displayTags = tagsArr.slice(0, 10);
219
+ const html = `
220
+ <!DOCTYPE html>
221
+ <html>
222
+ <head>
223
+ <style>
224
+ @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap');
225
+ body { margin: 0; padding: 0; font-family: 'Noto Sans SC', sans-serif; background: transparent; }
226
+ .card { width: 600px; height: 320px; background: #fff; border-radius: 16px; box-shadow: 0 10px 30px rgba(0,0,0,0.15); display: flex; overflow: hidden; }
227
+ .cover { width: 220px; height: 100%; ${bgStyle} background-size: cover; background-position: center; position: relative; flex-shrink: 0; }
228
+ .id-tag { position: absolute; top: 12px; left: 12px; background: rgba(0,0,0,0.6); color: #fff; padding: 4px 10px; border-radius: 6px; font-size: 13px; font-weight: bold; backdrop-filter: blur(4px); font-family: monospace; }
229
+ .info { flex: 1; padding: 24px; display: flex; flex-direction: column; justify-content: space-between; overflow: hidden; }
230
+
231
+ .title {
232
+ font-size: 22px; font-weight: 700; color: #333; line-height: 1.35;
233
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
234
+ }
235
+ .subtitle {
236
+ font-size: 15px; color: #555; margin-top: 6px; font-weight: 500;
237
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
238
+ padding-left: 10px; border-left: 3px solid #e91e63;
239
+ }
240
+
241
+ .author { font-size: 13px; color: #888; margin-top: 8px; font-weight: 400; }
242
+
243
+ .tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0; max-height: 56px; overflow: hidden; }
244
+ .tag { background: #eff2f5; color: #5c6b7f; padding: 3px 9px; border-radius: 4px; font-size: 11px; font-weight: 500; }
245
+ .tag-imp { background: #e3f2fd; color: #1565c0; }
246
+
247
+ .summary {
248
+ font-size: 13px; color: #666; line-height: 1.6;
249
+ display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
250
+ margin-top: auto;
251
+ }
252
+ .footer { border-top: 1px solid #eee; padding-top: 14px; display: flex; justify-content: space-between; font-size: 12px; color: #888; margin-top: 15px; }
253
+ .stat b { color: #555; font-weight: bold; margin-right: 2px;}
254
+ </style>
255
+ </head>
256
+ <body>
257
+ <div class="card">
258
+ <div class="cover"><div class="id-tag">ID: ${info.ID}</div></div>
259
+ <div class="info">
260
+ <div>
261
+ <div class="title">${displayTitle}</div>
262
+ ${subTitle ? `<div class="subtitle">${subTitle}</div>` : ""}
263
+ <div class="author">@${info.UserName}</div>
264
+ <div class="tags">${displayTags.map((t) => `<span class="tag ${["文", "译", "R"].includes(t) ? "tag-imp" : ""}">${t}</span>`).join("")}</div>
265
+ </div>
266
+ <div class="summary">${summary}</div>
267
+ <div class="footer">
268
+ <span class="stat"><b style="color:#009688">热度</b>${views}</span>
269
+ <span class="stat"><b style="color:#673ab7">评论</b>${comments}</span>
270
+ <span class="stat"><b style="color:#4caf50">赞</b>${likes}</span>
271
+ <span class="stat"><b style="color:#795548">字数</b>${words}</span>
202
272
  </div>
203
273
  </div>
204
- <div class="content">
205
- ${info.Content}
206
- </div>
207
- <div class="footer">
208
- FimTale Reader | ID: ${info.ID}
274
+ </div>
275
+ </body></html>`;
276
+ const page = await ctx.puppeteer.page();
277
+ await injectCookies(page);
278
+ await page.setContent(html);
279
+ await page.setViewport({ width: 640, height: 440, deviceScaleFactor: 2 });
280
+ const el = await page.$(".card");
281
+ const img = await el.screenshot({ type: "png" });
282
+ await page.close();
283
+ return img;
284
+ }, "renderCard");
285
+ const renderSearchResults = /* @__PURE__ */ __name(async (keyword, results) => {
286
+ const html = `
287
+ <!DOCTYPE html>
288
+ <html>
289
+ <head>
290
+ <style>
291
+ @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap');
292
+ body { margin: 0; padding: 0; font-family: 'Noto Sans SC', sans-serif; width: 500px; background: transparent; }
293
+ .container { background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 20px rgba(0,0,0,0.15); margin: 10px; }
294
+ .header { background: #fafafa; padding: 15px 20px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; }
295
+ .header-title { font-size: 16px; font-weight: bold; color: #333; }
296
+ .list { padding: 0; }
297
+ .item { display: flex; padding: 15px; border-bottom: 1px solid #f5f5f5; height: 110px; align-items: flex-start; }
298
+ .cover-box { width: 75px; height: 100%; border-radius: 6px; overflow: hidden; flex-shrink: 0; margin-right: 15px; background: #eee; position: relative; }
299
+ .cover-img { width: 100%; height: 100%; object-fit: cover; }
300
+ .content { flex: 1; display: flex; flex-direction: column; justify-content: space-between; height: 100%; min-width: 0; }
301
+ .top-row { display: flex; justify-content: space-between; align-items: flex-start; }
302
+ .title { font-size: 16px; font-weight: bold; color: #222; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 4px; flex:1; margin-right: 8px;}
303
+ .id-badge { background: #455a64; color: #fff; padding: 2px 6px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold; flex-shrink: 0; }
304
+ .author { font-size: 12px; color: #666; }
305
+ .tags { display: flex; gap: 4px; flex-wrap: wrap; height: 18px; overflow: hidden; margin-top: 4px; }
306
+ .tag { background: #f3f3f3; color: #666; padding: 0 5px; border-radius: 3px; font-size: 10px; white-space: nowrap; line-height: 1.6;}
307
+ .meta-row { display: flex; gap: 10px; font-size: 11px; color: #999; margin-top: auto; border-top: 1px dashed #eee; padding-top: 5px; }
308
+ .stat b { margin-right: 1px; font-weight: bold; }
309
+ </style>
310
+ </head>
311
+ <body>
312
+ <div class="container">
313
+ <div class="header"><div class="header-title">🔍 "${keyword}"</div><div>Top ${results.length}</div></div>
314
+ <div class="list">
315
+ ${results.map((r) => {
316
+ const bg = r.cover ? `<img class="cover-img" src="${r.cover}"/>` : `<div style="width:100%;height:100%;background:${generateGradient(r.title)}"></div>`;
317
+ const stats = [
318
+ r.stats.views && r.stats.views != "0" ? `<span class="stat" style="color:#009688"><b>热</b>${r.stats.views}</span>` : "",
319
+ r.stats.comments && r.stats.comments != "0" ? `<span class="stat" style="color:#673ab7"><b>评</b>${r.stats.comments}</span>` : "",
320
+ r.stats.likes && r.stats.likes != "0" ? `<span class="stat" style="color:#4caf50"><b>赞</b>${r.stats.likes}</span>` : "",
321
+ r.updateTime ? `<span class="stat" style="margin-left:auto;color:#757575">${r.updateTime}</span>` : ""
322
+ ].join("");
323
+ return `<div class="item"><div class="cover-box">${bg}</div><div class="content">
324
+ <div class="top-row"><div class="title">${r.title}</div><div class="id-badge">ID: ${r.id}</div></div>
325
+ <div class="author">By ${r.author} ${r.status ? ` · ${r.status}` : ""}</div>
326
+ <div class="tags">${r.tags.map((t) => `<span class="tag">${t}</span>`).join("")}</div>
327
+ <div class="meta-row">${stats || "No Data"}</div>
328
+ </div></div>`;
329
+ }).join("")}
209
330
  </div>
210
- </body>
211
- </html>`;
212
- let imgBuf;
331
+ </div>
332
+ </body></html>`;
333
+ const page = await ctx.puppeteer.page();
334
+ await page.setContent(html);
335
+ await page.setViewport({ width: 550, height: 800, deviceScaleFactor: 2 });
336
+ await sleep(300);
337
+ const el = await page.$(".container");
338
+ const img = await el.screenshot({ type: "png" });
339
+ await page.close();
340
+ return img;
341
+ }, "renderSearchResults");
342
+ const renderReadPages = /* @__PURE__ */ __name(async (info) => {
343
+ const html = `
344
+ <!DOCTYPE html>
345
+ <html>
346
+ <head>
347
+ <style>
348
+ @import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;700&display=swap');
349
+ body { margin: 0; padding: 0; width: ${config.deviceWidth}px; background-color: #f6f4ec; color: #2c2c2c; font-family: 'Noto Serif SC', serif; }
350
+ #source-container { display: none; }
351
+ .page {
352
+ width: ${config.deviceWidth}px; height: ${config.deviceHeight}px;
353
+ padding: 35px 28px; box-sizing: border-box;
354
+ position: relative; background: #f6f4ec; overflow: hidden;
355
+ display: flex; flex-direction: column;
356
+ }
357
+ .page-header {
358
+ font-size: 12px; color: #8d6e63; border-bottom: 2px solid #d7ccc8;
359
+ padding-bottom: 12px; margin-bottom: 20px; flex-shrink: 0;
360
+ display: flex; justify-content: space-between; font-weight: bold;
361
+ }
362
+ .page-footer {
363
+ position: absolute; bottom: 15px; left: 0; right: 0; text-align: center;
364
+ font-size: 12px; color: #aaa; font-family: sans-serif;
365
+ }
366
+ .page-content { flex: 1; overflow: hidden; font-size: ${config.fontSize}px; line-height: 1.8; text-align: justify; }
367
+ p { margin: 0 0 1em 0; text-indent: 2em; }
368
+ img { max-width: 100%; height: auto; display: block; margin: 15px auto; border-radius: 6px; box-shadow: 0 4px 10px rgba(0,0,0,0.1); }
369
+ </style>
370
+ </head>
371
+ <body>
372
+ <div id="source-container"><div class="meta-info" data-title="${info.Title}" data-author="${info.UserName}"></div>${info.Content}</div>
373
+ <div id="output"></div>
374
+ <script>
375
+ function paginate() {
376
+ const source = document.getElementById('source-container');
377
+ const output = document.getElementById('output');
378
+ const title = source.querySelector('.meta-info').dataset.title;
379
+ const author = source.querySelector('.meta-info').dataset.author;
380
+ let pageIndex = 1;
381
+ let currentPageContent = null;
382
+ function createNewPage() {
383
+ const page = document.createElement('div'); page.className = 'page';
384
+ const header = document.createElement('div'); header.className = 'page-header';
385
+ header.innerHTML = \`<span>\${title.substring(0, 12) + (title.length>12?'...':'')}</span><span>\${author}</span>\`;
386
+ page.appendChild(header);
387
+ const content = document.createElement('div'); content.className = 'page-content';
388
+ page.appendChild(content);
389
+ const footer = document.createElement('div'); footer.className = 'page-footer';
390
+ footer.id = 'footer-' + pageIndex;
391
+ page.appendChild(footer);
392
+ output.appendChild(page);
393
+ currentPageContent = content;
394
+ return page;
395
+ }
396
+ createNewPage();
397
+ const children = Array.from(source.children);
398
+ for (const child of children) {
399
+ if (child.className === 'meta-info') continue;
400
+ currentPageContent.appendChild(child.cloneNode(true));
401
+ if (currentPageContent.scrollHeight > currentPageContent.clientHeight) {
402
+ currentPageContent.removeChild(currentPageContent.lastChild);
403
+ pageIndex++;
404
+ createNewPage();
405
+ currentPageContent.appendChild(child.cloneNode(true));
406
+ }
407
+ }
408
+ for(let i=1; i<=pageIndex; i++) document.getElementById('footer-'+i).innerText = \`- \${i} / \${pageIndex} -\`;
409
+ return pageIndex;
410
+ }
411
+ </script>
412
+ </body></html>`;
413
+ const page = await ctx.puppeteer.page();
213
414
  try {
214
- const page = await ctx.puppeteer.page();
215
- await page.setContent(htmlContent);
216
- await new Promise((r) => setTimeout(r, 500));
217
- const body = await page.$("body");
218
- imgBuf = await body.screenshot({ type: "png" });
415
+ await injectCookies(page);
416
+ await page.setContent(html);
417
+ await page.setViewport({ width: config.deviceWidth, height: config.deviceHeight, deviceScaleFactor: 3 });
418
+ await page.evaluate("paginate()");
419
+ const imgs = [];
420
+ const pages = await page.$$(".page");
421
+ for (const p of pages) {
422
+ imgs.push(await p.screenshot({ type: "jpeg", quality: 90 }));
423
+ }
424
+ return imgs;
425
+ } finally {
219
426
  await page.close();
427
+ }
428
+ }, "renderReadPages");
429
+ ctx.command("ft.info <threadId:string>", "预览作品").action(async ({ session }, threadId) => {
430
+ if (!threadId) return "请输入ID";
431
+ const res = await fetchThread(threadId);
432
+ if (!res.valid) return `[Error] ${res.msg}`;
433
+ const img = await renderCard(res.data, res.parent);
434
+ return session.send(import_koishi.h.image(img, "image/png"));
435
+ });
436
+ ctx.command("ft.read <threadId:string>", "阅读章节").action(async ({ session }, threadId) => {
437
+ if (!threadId) return "请输入ID";
438
+ const res = await fetchThread(threadId);
439
+ if (!res.valid) return `[Error] 读取失败: ${res.msg}`;
440
+ await session.send(`[Reading] ${res.data.Title}...`);
441
+ try {
442
+ const cardImg = await renderCard(res.data, res.parent);
443
+ await session.send(import_koishi.h.image(cardImg, "image/png"));
444
+ const pages = await renderReadPages(res.data);
445
+ const nodes = pages.map((buf) => (0, import_koishi.h)("message", import_koishi.h.image(buf, "image/jpeg")));
446
+ const navs = [];
447
+ if (res.menu?.length) {
448
+ const idx = res.menu.findIndex((m) => m.ID.toString() === threadId);
449
+ if (idx > 0) navs.push(`[Prev] /ft.read ${res.menu[idx - 1].ID}`);
450
+ if (idx < res.menu.length - 1) navs.push(`[Next] /ft.read ${res.menu[idx + 1].ID}`);
451
+ }
452
+ if (navs.length) nodes.push((0, import_koishi.h)("message", import_koishi.h.text("Navigation:\n" + navs.join("\n"))));
453
+ return session.send((0, import_koishi.h)("message", { forward: true }, nodes));
220
454
  } catch (e) {
221
455
  ctx.logger("fimtale").error(e);
222
- return " 生成图片失败,请检查服务器 Puppeteer 配置。";
223
- }
224
- const messageElements = [
225
- import_koishi.h.image(imgBuf, "image/png")
226
- ];
227
- const navs = [];
228
- if (prevId) navs.push(`⬅️上一章: ft.read ${prevId}`);
229
- if (nextId) navs.push(`➡️下一章: ft.read ${nextId}`);
230
- if (!nextId && (!menu.length || menu.length === 0)) {
231
- navs.push(`(未检测到目录,尝试下页: ft.read ${parseInt(threadId) + 1})`);
456
+ return "[Error] 渲染失败";
232
457
  }
233
- if (navs.length > 0) {
234
- messageElements.push(import_koishi.h.text("\n" + navs.join("\n")));
458
+ });
459
+ ctx.command("ft.random", "随机作品").action(async ({ session }) => {
460
+ const id = await fetchRandomId();
461
+ if (!id) return "[Error] 获取失败";
462
+ const res = await fetchThread(id);
463
+ if (!res.valid) return `[Error] ID:${id} 读取失败`;
464
+ const img = await renderCard(res.data, res.parent);
465
+ await session.send(import_koishi.h.image(img, "image/png"));
466
+ return `Tip: 发送 /ft.read ${res.data.ID} 阅读全文`;
467
+ });
468
+ ctx.command("ft.search <keyword:text>", "搜索作品").action(async ({ session }, keyword) => {
469
+ if (!keyword) return "请输入关键词";
470
+ await session.send("[Search] 搜索中...");
471
+ const results = await searchThreads(keyword);
472
+ if (!results.length) return "未找到结果。";
473
+ const img = await renderSearchResults(keyword, results);
474
+ await session.send(import_koishi.h.image(img, "image/png"));
475
+ return "Tip: 发送 /ft.read <ID> 阅读";
476
+ });
477
+ ctx.command("ft.sub <threadId:string>", "订阅").action(async ({ session }, threadId) => {
478
+ if (!/^\d+$/.test(threadId)) return "ID错误";
479
+ const exist = await ctx.database.get("fimtale_subs", { cid: session.cid, threadId });
480
+ if (exist.length) return "已订阅";
481
+ const res = await fetchThread(threadId);
482
+ if (!res.valid) return "帖子不存在";
483
+ await ctx.database.create("fimtale_subs", { cid: session.cid, threadId, lastCount: res.data.Comments, lastCheck: Date.now() });
484
+ await session.send("[Success] 订阅成功");
485
+ const img = await renderCard(res.data, res.parent);
486
+ return session.send(import_koishi.h.image(img, "image/png"));
487
+ });
488
+ ctx.command("ft.unsub <threadId:string>", "退订").action(async ({ session }, threadId) => {
489
+ const res = await ctx.database.remove("fimtale_subs", { cid: session.cid, threadId });
490
+ return res.matched ? "[OK] 已退订" : "未找到订阅";
491
+ });
492
+ ctx.middleware(async (session, next) => {
493
+ if (!config.autoParseLink) return next();
494
+ const match = session.content.match(/fimtale\.com\/t\/(\d+)/);
495
+ if (match && match[1] && session.userId !== session.selfId) {
496
+ const res = await fetchThread(match[1]);
497
+ if (res.valid) {
498
+ const img = await renderCard(res.data, res.parent);
499
+ session.send(import_koishi.h.image(img, "image/png"));
500
+ }
235
501
  }
236
- return messageElements;
502
+ return next();
237
503
  });
238
504
  ctx.setInterval(async () => {
239
505
  const subs = await ctx.database.get("fimtale_subs", {});
240
506
  if (!subs.length) return;
241
- const threadIds = [...new Set(subs.map((s) => s.threadId))];
242
- for (const tid of threadIds) {
243
- const data = await fetchThread(tid);
244
- if (!data.valid) continue;
245
- const targets = subs.filter((s) => s.threadId === tid && s.lastCount < data.comments);
246
- if (targets.length > 0) {
247
- const msg = formatText(config.messages.updateAlert, {
248
- title: data.title,
249
- lastUser: data.lastUser || "神秘小马",
250
- count: data.comments,
251
- url: `https://fimtale.com/t/${tid}`
252
- });
507
+ const tids = [...new Set(subs.map((s) => s.threadId))];
508
+ for (const tid of tids) {
509
+ const res = await fetchThread(tid);
510
+ if (!res.valid) continue;
511
+ const targets = subs.filter((s) => s.threadId === tid && s.lastCount < res.data.Comments);
512
+ if (targets.length) {
513
+ const msg = `[Update] ${res.data.Title} 更新了!
514
+ 回复: ${res.data.Comments}
515
+ https://fimtale.com/t/${tid}`;
253
516
  for (const sub of targets) {
254
517
  try {
255
- await ctx.broadcast([sub.cid], msg);
256
- await ctx.database.set("fimtale_subs", { id: sub.id }, {
257
- lastCount: data.comments,
258
- lastCheck: Date.now()
259
- });
260
- } catch (e) {
518
+ await ctx.broadcast([sub.cid], import_koishi.h.parse(msg));
519
+ await ctx.database.set("fimtale_subs", { id: sub.id }, { lastCount: res.data.Comments });
520
+ } catch {
261
521
  }
262
522
  }
263
523
  }
264
524
  }
265
525
  }, config.pollInterval);
266
- ctx.middleware(async (session, next) => {
267
- if (!config.autoParseLink) return next();
268
- const match = session.content.match(/fimtale\.com\/t\/(\d+)/);
269
- if (match && match[1]) {
270
- if (session.userId === session.selfId) return next();
271
- const threadId = match[1];
272
- const data = await fetchThread(threadId);
273
- if (data.valid) {
274
- let preview = "";
275
- if (config.showPreview) {
276
- const rawText = stripHtml(data.content);
277
- preview = rawText.length > config.previewLength ? rawText.substring(0, config.previewLength) + "..." : rawText;
278
- }
279
- return session.send(formatText(config.messages.infoTemplate, {
280
- title: data.title,
281
- author: data.author,
282
- views: data.views,
283
- count: data.comments,
284
- time: data.lastTime,
285
- preview
286
- }));
287
- }
288
- }
289
- return next();
290
- });
291
526
  }
292
527
  __name(apply, "apply");
293
528
  // Annotate the CommonJS export names for ESM import in node:
package/package.json CHANGED
@@ -1,18 +1,20 @@
1
1
  {
2
2
  "name": "koishi-plugin-fimtale-api",
3
- "description": "自用Koishi插件,从fimtale-api获取信息",
4
- "version": "0.0.1",
3
+ "description": "Koishi插件,从fimtale搜索/订阅/随机获取小说/解析链接等",
4
+ "version": "0.0.3",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
8
8
  "lib",
9
9
  "dist"
10
10
  ],
11
+ "homepage": "https://github.com/muyni233/koishi-plugin-fimtale-api",
11
12
  "license": "MIT",
12
13
  "keywords": [
13
14
  "chatbot",
14
15
  "koishi",
15
- "plugin"
16
+ "plugin",
17
+ "fiction"
16
18
  ],
17
19
  "peerDependencies": {
18
20
  "koishi": "^4.18.7"
package/readme.md CHANGED
@@ -2,4 +2,6 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/koishi-plugin-fimtale-api?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-fimtale-api)
4
4
 
5
- Koishi插件,从fimtale-api获取信息
5
+ Koishi插件,从fimtale搜索/订阅/随机获取小说/解析链接等
6
+
7
+ > 使用Gemini-3-Pro-Preview协助完成