koishi-plugin-fimtale-api 0.0.2 → 0.0.4

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(20).description("正文字号(px)")
54
53
  });
55
54
  function apply(ctx, config) {
56
55
  ctx.model.extend("fimtale_subs", {
@@ -59,248 +58,476 @@ 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 "未知日期";
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 cleanContent = /* @__PURE__ */ __name((html) => {
84
+ if (!html) return "";
85
+ return html.replace(/<p>\s*&nbsp;\s*<\/p>/gi, "").replace(/<p>\s*<br\s*\/?>\s*<\/p>/gi, "").replace(/<p>\s*<\/p>/gi, "").replace(/(<br\s*\/?>){2,}/gi, "<br>").replace(/margin-bottom:\s*\d+px/gi, "");
86
+ }, "cleanContent");
87
+ const fontStack = '"Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", "WenQuanYi Micro Hei", Arial, sans-serif';
88
+ const injectCookies = /* @__PURE__ */ __name(async (page) => {
89
+ if (!config.cookies) return;
90
+ const cookies = config.cookies.split(";").map((pair) => {
91
+ const parts = pair.trim().split("=");
92
+ if (parts.length < 2) return null;
93
+ return { name: parts[0].trim(), value: parts.slice(1).join("=").trim(), domain: "fimtale.com", path: "/" };
94
+ }).filter((c) => c !== null);
95
+ if (cookies.length) await page.setCookie(...cookies);
96
+ }, "injectCookies");
77
97
  const fetchThread = /* @__PURE__ */ __name(async (threadId) => {
78
98
  try {
79
- const endpoint = `${config.apiUrl}/t/${threadId}`;
99
+ const url = `${config.apiUrl}/t/${threadId}`;
80
100
  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;
101
+ const res = await ctx.http.get(url, { params });
102
+ if (res.Status !== 1 || !res.TopicInfo) return { valid: false, msg: res.ErrorMessage || "API 返回错误" };
86
103
  return {
87
104
  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
105
+ data: res.TopicInfo,
106
+ parent: res.ParentInfo,
107
+ menu: res.Menu || []
102
108
  };
103
109
  } catch (e) {
104
- ctx.logger("fimtale").warn(`Fetch error for ${threadId}: ${e}`);
105
- return { valid: false };
110
+ return { valid: false, msg: "网络请求失败" };
106
111
  }
107
112
  }, "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;
113
+ const fetchRandomId = /* @__PURE__ */ __name(async () => {
114
+ try {
115
+ const headers = config.cookies ? { Cookie: config.cookies } : {};
116
+ const html = await ctx.http.get("https://fimtale.com/rand", { responseType: "text", headers });
117
+ let match = html.match(/FimTale\.topic\.init\((\d+)/) || html.match(/data-clipboard-text=".*?\/t\/(\d+)"/);
118
+ return match ? match[1] : null;
119
+ } catch (e) {
120
+ return null;
140
121
  }
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;
122
+ }, "fetchRandomId");
123
+ const searchThreads = /* @__PURE__ */ __name(async (keyword) => {
124
+ let page;
125
+ try {
126
+ page = await ctx.puppeteer.page();
127
+ 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");
128
+ await injectCookies(page);
129
+ const searchUrl = `https://fimtale.com/topics?q=${encodeURIComponent(keyword)}`;
130
+ await page.goto(searchUrl, { waitUntil: "networkidle2", timeout: 25e3 });
131
+ try {
132
+ await page.waitForSelector(".card", { timeout: 5e3 });
133
+ } catch {
163
134
  }
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
- const t = themes[config.readTheme];
171
- const htmlContent = `
172
- <!DOCTYPE html>
173
- <html>
174
- <head>
175
- <meta charset="UTF-8">
176
- <style>
177
- body {
178
- font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
179
- background-color: ${t.bg};
180
- color: ${t.text};
181
- padding: 40px;
182
- width: 480px; /* 稍微调窄一点,防止手机看不清 */
183
- line-height: 1.8;
184
- word-wrap: break-word;
135
+ const results = await page.evaluate(() => {
136
+ const items = [];
137
+ const cards = document.querySelectorAll(".card.topic-card");
138
+ cards.forEach((card) => {
139
+ if (items.length >= 6) return;
140
+ const link = card.querySelector('a[href^="/t/"]');
141
+ const href = link?.getAttribute("href");
142
+ const idMatch = href?.match(/^\/t\/(\d+)$/);
143
+ if (!idMatch) return;
144
+ const id = idMatch[1];
145
+ if (items.some((i) => i.id === id)) return;
146
+ let title = "";
147
+ const titleEl = card.querySelector(".card-title");
148
+ if (titleEl) title = titleEl.textContent?.trim() || "";
149
+ else title = link.textContent?.trim() || "";
150
+ let author = "未知";
151
+ const authorEl = card.querySelector('a[href^="/u/"] span.grey-text');
152
+ if (authorEl) author = authorEl.textContent?.trim() || "";
153
+ let cover = void 0;
154
+ const imgEl = card.querySelector(".card-image img");
155
+ if (imgEl) {
156
+ const src = imgEl.getAttribute("src");
157
+ if (src && (!src.includes("avatar") || src.includes("upload"))) cover = src;
158
+ }
159
+ const tags = [];
160
+ let status = "";
161
+ card.querySelectorAll(".main-tag-set div").forEach((b) => {
162
+ const t = b.textContent?.trim();
163
+ if (t) tags.push(t);
164
+ });
165
+ card.querySelectorAll(".chip").forEach((c) => {
166
+ const t = c.textContent?.trim();
167
+ if (!t) return;
168
+ if (["连载中", "已完结", "已弃坑"].includes(t)) status = t;
169
+ else if (!t.includes("展开其余")) tags.push(t);
170
+ });
171
+ const stats = { views: "0", comments: "0", likes: "0", words: "0" };
172
+ const actionDiv = card.querySelector(".card-action > div");
173
+ if (actionDiv) {
174
+ actionDiv.querySelectorAll("span[title]").forEach((s) => {
175
+ const t = s.getAttribute("title") || "";
176
+ const v = s.textContent?.trim().replace(/,/g, "") || "0";
177
+ if (t.includes("字")) stats.words = v;
178
+ if (t.includes("阅读")) stats.views = v;
179
+ if (t.includes("评论")) stats.comments = v;
180
+ });
181
+ }
182
+ const greenText = card.querySelector(".left.green-text");
183
+ if (greenText) stats.likes = greenText.textContent?.replace(/[^0-9]/g, "") || "0";
184
+ let updateTime = "";
185
+ const timeSpan = card.querySelector('div[style*="margin: 3px 0;"] span.grey-text');
186
+ if (timeSpan) {
187
+ const txt = timeSpan.textContent || "";
188
+ 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*日)/);
189
+ if (dateMatch) updateTime = dateMatch[1].replace(/\s/g, "");
185
190
  }
186
- .header { margin-bottom: 25px; border-bottom: 2px solid ${t.accent}; padding-bottom: 15px; }
187
- .title { font-size: 26px; font-weight: bold; margin-bottom: 8px; }
188
- .meta { font-size: 14px; opacity: 0.8; }
189
- .content { font-size: 18px; text-align: justify; }
190
- .content p { margin-bottom: 1.2em; text-indent: 2em; }
191
- /* 限制图片最大高度,防止一张图把 puppeteer 撑爆 */
192
- .content img { max-width: 100%; max-height: 800px; height: auto; border-radius: 6px; display: block; margin: 10px auto; }
193
- .footer { margin-top: 40px; text-align: center; font-size: 12px; opacity: 0.6; }
194
- </style>
195
- </head>
196
- <body>
197
- <div class="header">
198
- <div class="title">${info.Title}</div>
199
- <div class="meta">
200
- 作者:${info.UserName} &nbsp;|&nbsp; ${import_koishi.Time.format(info.DateCreated * 1e3).split(" ")[0]}
191
+ items.push({ id, title, author, cover, tags: tags.slice(0, 8), status, stats, updateTime });
192
+ });
193
+ return items;
194
+ });
195
+ return results;
196
+ } catch (e) {
197
+ return [];
198
+ } finally {
199
+ if (page) await page.close();
200
+ }
201
+ }, "searchThreads");
202
+ const renderCard = /* @__PURE__ */ __name(async (info, parent) => {
203
+ const isChapter = info.IsChapter || !!parent && parent.ID !== info.ID;
204
+ const displayTitle = isChapter && parent ? parent.Title : info.Title;
205
+ const displayCover = isChapter && parent ? parent.Background || extractImage(parent.Content) : info.Background || extractImage(info.Content);
206
+ const displayTagsObj = isChapter && parent ? parent.Tags : info.Tags;
207
+ const subTitle = isChapter ? info.Title : null;
208
+ const views = info.Views || 0;
209
+ const comments = info.Comments || 0;
210
+ const words = info.WordCount || 0;
211
+ const likes = info.Upvotes || 0;
212
+ const bgStyle = displayCover ? `background-image: url('${displayCover}');` : `background: ${generateGradient(displayTitle)};`;
213
+ let summary = stripHtml(info.Content);
214
+ if (summary.length < 10 && parent && isChapter) summary = stripHtml(parent.Content);
215
+ if (summary.length > 100) summary = summary.substring(0, 100) + "...";
216
+ if (!summary) summary = "暂无简介";
217
+ const tagsArr = [];
218
+ if (displayTagsObj?.Type) tagsArr.push(displayTagsObj.Type);
219
+ if (displayTagsObj?.Rating && displayTagsObj.Rating !== "E") tagsArr.push(displayTagsObj.Rating);
220
+ if (displayTagsObj?.OtherTags && Array.isArray(displayTagsObj.OtherTags)) {
221
+ tagsArr.push(...displayTagsObj.OtherTags);
222
+ }
223
+ const displayTags = tagsArr.slice(0, 10);
224
+ const html = `
225
+ <!DOCTYPE html>
226
+ <html>
227
+ <head>
228
+ <style>
229
+ body { margin: 0; padding: 0; font-family: ${fontStack}; background: transparent; }
230
+ .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; }
231
+ .cover { width: 220px; height: 100%; ${bgStyle} background-size: cover; background-position: center; position: relative; flex-shrink: 0; }
232
+ .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; }
233
+ .info { flex: 1; padding: 24px; display: flex; flex-direction: column; justify-content: space-between; overflow: hidden; }
234
+
235
+ .title {
236
+ font-size: 22px; font-weight: 700; color: #333; line-height: 1.35;
237
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
238
+ }
239
+ .subtitle {
240
+ font-size: 15px; color: #555; margin-top: 6px; font-weight: 500;
241
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
242
+ padding-left: 10px; border-left: 3px solid #e91e63;
243
+ }
244
+
245
+ .author { font-size: 13px; color: #888; margin-top: 8px; font-weight: 400; }
246
+
247
+ .tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0; max-height: 56px; overflow: hidden; }
248
+ .tag { background: #eff2f5; color: #5c6b7f; padding: 3px 9px; border-radius: 4px; font-size: 11px; font-weight: 500; }
249
+ .tag-imp { background: #e3f2fd; color: #1565c0; }
250
+
251
+ .summary {
252
+ font-size: 13px; color: #666; line-height: 1.6;
253
+ display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
254
+ margin-top: auto;
255
+ }
256
+ .footer { border-top: 1px solid #eee; padding-top: 14px; display: flex; justify-content: space-between; font-size: 12px; color: #888; margin-top: 15px; }
257
+ .stat b { color: #555; font-weight: bold; margin-right: 2px;}
258
+ </style>
259
+ </head>
260
+ <body>
261
+ <div class="card">
262
+ <div class="cover"><div class="id-tag">ID: ${info.ID}</div></div>
263
+ <div class="info">
264
+ <div>
265
+ <div class="title">${displayTitle}</div>
266
+ ${subTitle ? `<div class="subtitle">${subTitle}</div>` : ""}
267
+ <div class="author">@${info.UserName}</div>
268
+ <div class="tags">${displayTags.map((t) => `<span class="tag ${["文", "译", "R"].includes(t) ? "tag-imp" : ""}">${t}</span>`).join("")}</div>
269
+ </div>
270
+ <div class="summary">${summary}</div>
271
+ <div class="footer">
272
+ <span class="stat"><b style="color:#009688">热度</b>${views}</span>
273
+ <span class="stat"><b style="color:#673ab7">评论</b>${comments}</span>
274
+ <span class="stat"><b style="color:#4caf50">赞</b>${likes}</span>
275
+ <span class="stat"><b style="color:#795548">字数</b>${words}</span>
201
276
  </div>
202
277
  </div>
203
- <div class="content">
204
- ${info.Content}
205
- </div>
206
- <div class="footer">
207
- FimTale Reader | ID: ${info.ID}
278
+ </div>
279
+ </body></html>`;
280
+ const page = await ctx.puppeteer.page();
281
+ await injectCookies(page);
282
+ await page.setContent(html);
283
+ await page.setViewport({ width: 640, height: 440, deviceScaleFactor: 2 });
284
+ const el = await page.$(".card");
285
+ const img = await el.screenshot({ type: "png" });
286
+ await page.close();
287
+ return img;
288
+ }, "renderCard");
289
+ const renderSearchResults = /* @__PURE__ */ __name(async (keyword, results) => {
290
+ const html = `
291
+ <!DOCTYPE html>
292
+ <html>
293
+ <head>
294
+ <style>
295
+ body { margin: 0; padding: 0; font-family: ${fontStack}; width: 500px; background: transparent; }
296
+ .container { background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 20px rgba(0,0,0,0.15); margin: 10px; }
297
+ .header { background: #fafafa; padding: 15px 20px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; }
298
+ .header-title { font-size: 16px; font-weight: bold; color: #333; }
299
+ .list { padding: 0; }
300
+ .item { display: flex; padding: 15px; border-bottom: 1px solid #f5f5f5; height: 110px; align-items: flex-start; }
301
+ .cover-box { width: 75px; height: 100%; border-radius: 6px; overflow: hidden; flex-shrink: 0; margin-right: 15px; background: #eee; position: relative; }
302
+ .cover-img { width: 100%; height: 100%; object-fit: cover; }
303
+ .content { flex: 1; display: flex; flex-direction: column; justify-content: space-between; height: 100%; min-width: 0; }
304
+ .top-row { display: flex; justify-content: space-between; align-items: flex-start; }
305
+ .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;}
306
+ .id-badge { background: #455a64; color: #fff; padding: 2px 6px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold; flex-shrink: 0; }
307
+ .author { font-size: 12px; color: #666; }
308
+ .tags { display: flex; gap: 4px; flex-wrap: wrap; height: 18px; overflow: hidden; margin-top: 4px; }
309
+ .tag { background: #f3f3f3; color: #666; padding: 0 5px; border-radius: 3px; font-size: 10px; white-space: nowrap; line-height: 1.6;}
310
+ .meta-row { display: flex; gap: 10px; font-size: 11px; color: #999; margin-top: auto; border-top: 1px dashed #eee; padding-top: 5px; }
311
+ .stat b { margin-right: 1px; font-weight: bold; }
312
+ </style>
313
+ </head>
314
+ <body>
315
+ <div class="container">
316
+ <div class="header"><div class="header-title">🔍 "${keyword}"</div><div>Top ${results.length}</div></div>
317
+ <div class="list">
318
+ ${results.map((r) => {
319
+ const bg = r.cover ? `<img class="cover-img" src="${r.cover}"/>` : `<div style="width:100%;height:100%;background:${generateGradient(r.title)}"></div>`;
320
+ const stats = [
321
+ r.stats.views && r.stats.views != "0" ? `<span class="stat" style="color:#009688"><b>热</b>${r.stats.views}</span>` : "",
322
+ r.stats.comments && r.stats.comments != "0" ? `<span class="stat" style="color:#673ab7"><b>评</b>${r.stats.comments}</span>` : "",
323
+ r.stats.likes && r.stats.likes != "0" ? `<span class="stat" style="color:#4caf50"><b>赞</b>${r.stats.likes}</span>` : "",
324
+ r.updateTime ? `<span class="stat" style="margin-left:auto;color:#757575">${r.updateTime}</span>` : ""
325
+ ].join("");
326
+ return `<div class="item"><div class="cover-box">${bg}</div><div class="content">
327
+ <div class="top-row"><div class="title">${r.title}</div><div class="id-badge">ID: ${r.id}</div></div>
328
+ <div class="author">By ${r.author} ${r.status ? ` · ${r.status}` : ""}</div>
329
+ <div class="tags">${r.tags.map((t) => `<span class="tag">${t}</span>`).join("")}</div>
330
+ <div class="meta-row">${stats || "暂无数据"}</div>
331
+ </div></div>`;
332
+ }).join("")}
208
333
  </div>
209
- </body>
210
- </html>`;
211
- const messageElements = [];
212
- try {
213
- const page = await ctx.puppeteer.page();
214
- await page.setContent(htmlContent);
215
- await new Promise((r) => setTimeout(r, 500));
216
- const bodyHeight = await page.evaluate(() => document.body.scrollHeight);
217
- const MAX_HEIGHT = 5e3;
218
- if (bodyHeight <= MAX_HEIGHT) {
219
- const imgBuf = await page.screenshot({ fullPage: true, type: "jpeg", quality: 80 });
220
- messageElements.push(import_koishi.h.image(imgBuf, "image/jpeg"));
221
- } else {
222
- const viewportHeight = 4e3;
223
- let currentScroll = 0;
224
- await page.setViewport({ width: 560, height: viewportHeight });
225
- while (currentScroll < bodyHeight) {
226
- const imgBuf = await page.screenshot({ type: "jpeg", quality: 100 });
227
- messageElements.push(import_koishi.h.image(imgBuf, "image/jpeg"));
228
- currentScroll += viewportHeight;
229
- if (currentScroll < bodyHeight) {
230
- await page.evaluate((y) => window.scrollTo(0, y), currentScroll);
231
- await new Promise((r) => setTimeout(r, 200));
334
+ </div>
335
+ </body></html>`;
336
+ const page = await ctx.puppeteer.page();
337
+ await page.setContent(html);
338
+ await page.setViewport({ width: 550, height: 800, deviceScaleFactor: 2 });
339
+ await sleep(300);
340
+ const el = await page.$(".container");
341
+ const img = await el.screenshot({ type: "png" });
342
+ await page.close();
343
+ return img;
344
+ }, "renderSearchResults");
345
+ const renderReadPages = /* @__PURE__ */ __name(async (info) => {
346
+ const cleanedContent = cleanContent(info.Content);
347
+ const html = `
348
+ <!DOCTYPE html>
349
+ <html>
350
+ <head>
351
+ <style>
352
+ body { margin: 0; padding: 0; width: ${config.deviceWidth}px; background-color: #f6f4ec; color: #2c2c2c; font-family: ${fontStack}; }
353
+ #source-container { display: none; }
354
+ .page {
355
+ width: ${config.deviceWidth}px; height: ${config.deviceHeight}px;
356
+ padding: 35px 28px; box-sizing: border-box;
357
+ position: relative; background: #f6f4ec; overflow: hidden;
358
+ display: flex; flex-direction: column;
359
+ }
360
+ .page-header {
361
+ font-size: 12px; color: #8d6e63; border-bottom: 2px solid #d7ccc8;
362
+ padding-bottom: 12px; margin-bottom: 15px; flex-shrink: 0;
363
+ display: flex; justify-content: space-between; font-weight: bold;
364
+ }
365
+ .page-footer {
366
+ position: absolute; bottom: 15px; left: 0; right: 0; text-align: center;
367
+ font-size: 12px; color: #aaa; font-family: sans-serif;
368
+ }
369
+ .page-content { flex: 1; overflow: hidden; font-size: ${config.fontSize}px; line-height: 1.7; text-align: justify; }
370
+ /* 段落样式优化 */
371
+ p { margin: 0 0 0.6em 0; text-indent: 2em; }
372
+ img { max-width: 100%; height: auto; display: block; margin: 10px auto; border-radius: 6px; box-shadow: 0 4px 10px rgba(0,0,0,0.1); }
373
+ h1, h2, h3 { font-size: 1.1em; margin: 0.5em 0; color: #5d4037; text-indent: 0; font-weight: bold; }
374
+ </style>
375
+ </head>
376
+ <body>
377
+ <div id="source-container"><div class="meta-info" data-title="${info.Title}" data-author="${info.UserName}"></div>${cleanedContent}</div>
378
+ <div id="output"></div>
379
+ <script>
380
+ function paginate() {
381
+ const source = document.getElementById('source-container');
382
+ const output = document.getElementById('output');
383
+ const title = source.querySelector('.meta-info').dataset.title;
384
+ const author = source.querySelector('.meta-info').dataset.author;
385
+ let pageIndex = 1;
386
+ let currentPageContent = null;
387
+ function createNewPage() {
388
+ const page = document.createElement('div'); page.className = 'page';
389
+ const header = document.createElement('div'); header.className = 'page-header';
390
+ header.innerHTML = \`<span>\${title.substring(0, 12) + (title.length>12?'...':'')}</span><span>\${author}</span>\`;
391
+ page.appendChild(header);
392
+ const content = document.createElement('div'); content.className = 'page-content';
393
+ page.appendChild(content);
394
+ const footer = document.createElement('div'); footer.className = 'page-footer';
395
+ footer.id = 'footer-' + pageIndex;
396
+ page.appendChild(footer);
397
+ output.appendChild(page);
398
+ currentPageContent = content;
399
+ return page;
400
+ }
401
+ createNewPage();
402
+ const children = Array.from(source.children);
403
+ for (const child of children) {
404
+ if (child.className === 'meta-info') continue;
405
+ currentPageContent.appendChild(child.cloneNode(true));
406
+ if (currentPageContent.scrollHeight > currentPageContent.clientHeight) {
407
+ currentPageContent.removeChild(currentPageContent.lastChild);
408
+ pageIndex++;
409
+ createNewPage();
410
+ currentPageContent.appendChild(child.cloneNode(true));
411
+ }
232
412
  }
413
+ for(let i=1; i<=pageIndex; i++) document.getElementById('footer-'+i).innerText = \`- \${i} / \${pageIndex} -\`;
414
+ return pageIndex;
233
415
  }
416
+ </script>
417
+ </body></html>`;
418
+ const page = await ctx.puppeteer.page();
419
+ try {
420
+ await injectCookies(page);
421
+ await page.setContent(html);
422
+ await page.setViewport({ width: config.deviceWidth, height: config.deviceHeight, deviceScaleFactor: 2 });
423
+ await page.evaluate("paginate()");
424
+ const imgs = [];
425
+ const pages = await page.$$(".page");
426
+ for (const p of pages) {
427
+ imgs.push(await p.screenshot({ type: "jpeg", quality: 80 }));
234
428
  }
429
+ return imgs;
430
+ } finally {
235
431
  await page.close();
432
+ }
433
+ }, "renderReadPages");
434
+ ctx.command("ft.info <threadId:string>", "预览作品").action(async ({ session }, threadId) => {
435
+ if (!threadId) return "请输入ID";
436
+ const res = await fetchThread(threadId);
437
+ if (!res.valid) return `[错误] ${res.msg}`;
438
+ const img = await renderCard(res.data, res.parent);
439
+ return session.send(import_koishi.h.image(img, "image/png"));
440
+ });
441
+ ctx.command("ft.read <threadId:string>", "阅读章节").action(async ({ session }, threadId) => {
442
+ if (!threadId) return "请输入ID";
443
+ const res = await fetchThread(threadId);
444
+ if (!res.valid) return `[错误] 读取失败: ${res.msg}`;
445
+ await session.send(`[加载中] ${res.data.Title}...`);
446
+ try {
447
+ const cardImg = await renderCard(res.data, res.parent);
448
+ await session.send(import_koishi.h.image(cardImg, "image/png"));
449
+ const pages = await renderReadPages(res.data);
450
+ const nodes = pages.map((buf) => (0, import_koishi.h)("message", import_koishi.h.image(buf, "image/jpeg")));
451
+ const navs = [];
452
+ if (res.menu?.length) {
453
+ const idx = res.menu.findIndex((m) => m.ID.toString() === threadId);
454
+ if (idx > 0) navs.push(`[上一章] /ft.read ${res.menu[idx - 1].ID}`);
455
+ if (idx < res.menu.length - 1) navs.push(`[下一章] /ft.read ${res.menu[idx + 1].ID}`);
456
+ }
457
+ if (navs.length) nodes.push((0, import_koishi.h)("message", import_koishi.h.text("章节导航:\n" + navs.join("\n"))));
458
+ return session.send((0, import_koishi.h)("message", { forward: true }, nodes));
236
459
  } catch (e) {
237
460
  ctx.logger("fimtale").error(e);
238
- return " 图片生成失败:内容过长或 puppeteer 错误。";
239
- }
240
- const navs = [];
241
- if (prevId) navs.push(`⬅️上一章: ft.read ${prevId}`);
242
- if (nextId) navs.push(`➡️下一章: ft.read ${nextId}`);
243
- if (!nextId && (!menu.length || menu.length === 0)) {
244
- navs.push(`(未检测到目录,尝试下页: ft.read ${parseInt(threadId) + 1})`);
461
+ return "[错误] 渲染失败";
245
462
  }
246
- if (navs.length > 0) {
247
- messageElements.push(import_koishi.h.text("\n" + navs.join("\n")));
463
+ });
464
+ ctx.command("ft.random", "随机作品").action(async ({ session }) => {
465
+ const id = await fetchRandomId();
466
+ if (!id) return "[错误] 获取失败";
467
+ const res = await fetchThread(id);
468
+ if (!res.valid) return `[错误] ID:${id} 读取失败`;
469
+ const img = await renderCard(res.data, res.parent);
470
+ await session.send(import_koishi.h.image(img, "image/png"));
471
+ return `提示: 发送 /ft.read ${res.data.ID} 阅读全文`;
472
+ });
473
+ ctx.command("ft.search <keyword:text>", "搜索作品").action(async ({ session }, keyword) => {
474
+ if (!keyword) return "请输入关键词";
475
+ await session.send("[加载中] 搜索中...");
476
+ const results = await searchThreads(keyword);
477
+ if (!results.length) return "未找到结果。";
478
+ const img = await renderSearchResults(keyword, results);
479
+ await session.send(import_koishi.h.image(img, "image/png"));
480
+ return "提示: 发送 /ft.read <ID> 阅读";
481
+ });
482
+ ctx.command("ft.sub <threadId:string>", "订阅").action(async ({ session }, threadId) => {
483
+ if (!/^\d+$/.test(threadId)) return "ID错误";
484
+ const exist = await ctx.database.get("fimtale_subs", { cid: session.cid, threadId });
485
+ if (exist.length) return "已订阅";
486
+ const res = await fetchThread(threadId);
487
+ if (!res.valid) return "帖子不存在";
488
+ await ctx.database.create("fimtale_subs", { cid: session.cid, threadId, lastCount: res.data.Comments, lastCheck: Date.now() });
489
+ await session.send("[成功] 订阅成功");
490
+ const img = await renderCard(res.data, res.parent);
491
+ return session.send(import_koishi.h.image(img, "image/png"));
492
+ });
493
+ ctx.command("ft.unsub <threadId:string>", "退订").action(async ({ session }, threadId) => {
494
+ const res = await ctx.database.remove("fimtale_subs", { cid: session.cid, threadId });
495
+ return res.matched ? "[成功] 已退订" : "未找到订阅";
496
+ });
497
+ ctx.middleware(async (session, next) => {
498
+ if (!config.autoParseLink) return next();
499
+ const match = session.content.match(/fimtale\.com\/t\/(\d+)/);
500
+ if (match && match[1] && session.userId !== session.selfId) {
501
+ const res = await fetchThread(match[1]);
502
+ if (res.valid) {
503
+ const img = await renderCard(res.data, res.parent);
504
+ session.send(import_koishi.h.image(img, "image/png"));
505
+ }
248
506
  }
249
- return messageElements;
507
+ return next();
250
508
  });
251
509
  ctx.setInterval(async () => {
252
510
  const subs = await ctx.database.get("fimtale_subs", {});
253
511
  if (!subs.length) return;
254
- const threadIds = [...new Set(subs.map((s) => s.threadId))];
255
- for (const tid of threadIds) {
256
- const data = await fetchThread(tid);
257
- if (!data.valid) continue;
258
- const targets = subs.filter((s) => s.threadId === tid && s.lastCount < data.comments);
259
- if (targets.length > 0) {
260
- const msg = formatText(config.messages.updateAlert, {
261
- title: data.title,
262
- lastUser: data.lastUser || "神秘小马",
263
- count: data.comments,
264
- url: `https://fimtale.com/t/${tid}`
265
- });
512
+ const tids = [...new Set(subs.map((s) => s.threadId))];
513
+ for (const tid of tids) {
514
+ const res = await fetchThread(tid);
515
+ if (!res.valid) continue;
516
+ const targets = subs.filter((s) => s.threadId === tid && s.lastCount < res.data.Comments);
517
+ if (targets.length) {
518
+ const msg = `[更新] ${res.data.Title} 更新了!
519
+ 回复: ${res.data.Comments}
520
+ https://fimtale.com/t/${tid}`;
266
521
  for (const sub of targets) {
267
522
  try {
268
- await ctx.broadcast([sub.cid], msg);
269
- await ctx.database.set("fimtale_subs", { id: sub.id }, {
270
- lastCount: data.comments,
271
- lastCheck: Date.now()
272
- });
273
- } catch (e) {
523
+ await ctx.broadcast([sub.cid], import_koishi.h.parse(msg));
524
+ await ctx.database.set("fimtale_subs", { id: sub.id }, { lastCount: res.data.Comments });
525
+ } catch {
274
526
  }
275
527
  }
276
528
  }
277
529
  }
278
530
  }, config.pollInterval);
279
- ctx.middleware(async (session, next) => {
280
- if (!config.autoParseLink) return next();
281
- const match = session.content.match(/fimtale\.com\/t\/(\d+)/);
282
- if (match && match[1]) {
283
- if (session.userId === session.selfId) return next();
284
- const threadId = match[1];
285
- const data = await fetchThread(threadId);
286
- if (data.valid) {
287
- let preview = "";
288
- if (config.showPreview) {
289
- const rawText = stripHtml(data.content);
290
- preview = rawText.length > config.previewLength ? rawText.substring(0, config.previewLength) + "..." : rawText;
291
- }
292
- return session.send(formatText(config.messages.infoTemplate, {
293
- title: data.title,
294
- author: data.author,
295
- views: data.views,
296
- count: data.comments,
297
- time: data.lastTime,
298
- preview
299
- }));
300
- }
301
- }
302
- return next();
303
- });
304
531
  }
305
532
  __name(apply, "apply");
306
533
  // 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.2",
3
+ "description": "Koishi插件,从fimtale搜索/订阅/随机获取小说/解析链接等",
4
+ "version": "0.0.4",
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协助完成