koishi-plugin-fimtale-api 0.0.9 → 0.0.98

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.
Files changed (2) hide show
  1. package/lib/index.js +107 -103
  2. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -47,7 +47,7 @@ var Config = import_koishi.Schema.object({
47
47
  cookies: import_koishi.Schema.string().role("secret").description("浏览器 Cookie (用于解除安全模式,必填)"),
48
48
  pollInterval: import_koishi.Schema.number().default(60 * 1e3).description("追更轮询间隔(ms)"),
49
49
  autoParseLink: import_koishi.Schema.boolean().default(true).description("自动解析链接为预览卡片"),
50
- // 渲染配置 (iPhone 12/13/14 标准)
50
+ // 渲染配置 (标准手机比例)
51
51
  deviceWidth: import_koishi.Schema.number().default(390).description("阅读器渲染宽度(px)"),
52
52
  deviceHeight: import_koishi.Schema.number().default(844).description("阅读器渲染高度(px)"),
53
53
  fontSize: import_koishi.Schema.number().default(20).description("正文字号(px)")
@@ -61,11 +61,6 @@ function apply(ctx, config) {
61
61
  lastCheck: "integer"
62
62
  }, { primary: "id", autoInc: true });
63
63
  const sleep = /* @__PURE__ */ __name((ms) => new Promise((resolve) => setTimeout(resolve, ms)), "sleep");
64
- const formatDate = /* @__PURE__ */ __name((timestamp) => {
65
- if (!timestamp) return "未知日期";
66
- const date = new Date(timestamp * 1e3);
67
- return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")}`;
68
- }, "formatDate");
69
64
  const stripHtml = /* @__PURE__ */ __name((html) => {
70
65
  if (!html) return "";
71
66
  return html.replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
@@ -83,7 +78,7 @@ function apply(ctx, config) {
83
78
  }, "generateGradient");
84
79
  const cleanContent = /* @__PURE__ */ __name((html) => {
85
80
  if (!html) return "";
86
- 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, "");
81
+ return html.replace(/style="[^"]*"/gi, "").replace(/<p[^>]*>\s*(&nbsp;|<br\s*\/?>|\s)*\s*<\/p>/gi, "").replace(/(<br\s*\/?>\s*){2,}/gi, "<br>").trim();
87
82
  }, "cleanContent");
88
83
  const fontStack = '"Noto Sans SC", "Microsoft YaHei", "PingFang SC", sans-serif';
89
84
  const fontSerif = '"Noto Serif SC", "Source Han Serif SC", "SimSun", serif';
@@ -101,7 +96,7 @@ function apply(ctx, config) {
101
96
  const url = `${config.apiUrl}/t/${threadId}`;
102
97
  const params = { APIKey: config.apiKey, APIPass: config.apiPass };
103
98
  const res = await ctx.http.get(url, { params });
104
- if (res.Status !== 1 || !res.TopicInfo) return { valid: false, msg: res.ErrorMessage || "API 返回错误" };
99
+ if (res.Status !== 1 || !res.TopicInfo) return { valid: false, msg: res.ErrorMessage || "API Error" };
105
100
  return {
106
101
  valid: true,
107
102
  data: res.TopicInfo,
@@ -109,7 +104,7 @@ function apply(ctx, config) {
109
104
  menu: res.Menu || []
110
105
  };
111
106
  } catch (e) {
112
- return { valid: false, msg: "网络请求失败" };
107
+ return { valid: false, msg: "Request Failed" };
113
108
  }
114
109
  }, "fetchThread");
115
110
  const fetchRandomId = /* @__PURE__ */ __name(async () => {
@@ -149,7 +144,7 @@ function apply(ctx, config) {
149
144
  const titleEl = card.querySelector(".card-title");
150
145
  if (titleEl) title = titleEl.textContent?.trim() || "";
151
146
  else title = link.textContent?.trim() || "";
152
- let author = "未知";
147
+ let author = "Unknown";
153
148
  const authorEl = card.querySelector('a[href^="/u/"] span.grey-text');
154
149
  if (authorEl) author = authorEl.textContent?.trim() || "";
155
150
  let cover = void 0;
@@ -220,7 +215,7 @@ function apply(ctx, config) {
220
215
  const bgStyle = displayCover ? `background-image: url('${displayCover}');` : `background: ${generateGradient(displayTitle)};`;
221
216
  let summary = stripHtml(info.Content);
222
217
  if (summary.length < 10 && parent && isChapter) summary = stripHtml(parent.Content);
223
- if (summary.length > 110) summary = summary.substring(0, 110) + "...";
218
+ if (summary.length > 150) summary = summary.substring(0, 150) + "...";
224
219
  if (!summary) summary = "暂无简介";
225
220
  const tagsArr = [];
226
221
  if (displayTagsObj?.Type) tagsArr.push(displayTagsObj.Type);
@@ -235,52 +230,54 @@ function apply(ctx, config) {
235
230
  <head>
236
231
  <style>
237
232
  body { margin: 0; padding: 0; font-family: ${fontStack}; background: transparent; }
238
- .card { width: 620px; height: 340px; background: #fff; border-radius: 16px; box-shadow: 0 10px 30px rgba(0,0,0,0.15); display: flex; overflow: hidden; }
233
+ .card { width: 620px; height: 360px; background: #fff; border-radius: 16px; box-shadow: 0 10px 30px rgba(0,0,0,0.15); display: flex; overflow: hidden; }
234
+
239
235
  .cover { width: 220px; height: 100%; ${bgStyle} background-size: cover; background-position: center; position: relative; flex-shrink: 0; }
240
236
  .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; }
241
237
 
242
- .info {
243
- flex: 1; padding: 24px;
244
- display: flex; flex-direction: column;
245
- /* 关键:内容溢出隐藏,防止撑开 */
246
- overflow: hidden;
247
- }
248
-
249
- .header-group { display: flex; flex-direction: column; flex-shrink: 0; }
238
+ .info { flex: 1; padding: 24px; display: flex; flex-direction: column; overflow: hidden; position: relative; }
250
239
 
240
+ .header-group { flex-shrink: 0; margin-bottom: 10px; }
251
241
  .title {
252
- font-size: 22px; font-weight: 700; color: #333; line-height: 1.35;
242
+ font-size: 22px; font-weight: 700; color: #333; line-height: 1.4;
253
243
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
244
+ margin-bottom: 4px;
254
245
  }
255
246
  .subtitle {
256
- font-size: 15px; color: #555; margin-top: 6px; font-weight: 500;
247
+ font-size: 15px; color: #555; font-weight: 500;
257
248
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
258
249
  padding-left: 10px; border-left: 3px solid #e91e63;
250
+ margin-top: 4px;
259
251
  }
260
- .author { font-size: 13px; color: #888; margin-top: 8px; font-weight: 400; }
252
+ .author { font-size: 13px; color: #888; margin-top: 6px; font-weight: 400; }
261
253
 
262
- /* 标签栏 flex-shrink 设为 0,保证标签不被压缩 */
263
- .tags {
264
- display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0;
265
- max-height: 56px; overflow: hidden; flex-shrink: 0;
266
- }
254
+ .tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; flex-shrink: 0; }
267
255
  .tag { background: #eff2f5; color: #5c6b7f; padding: 3px 9px; border-radius: 4px; font-size: 11px; font-weight: 500; }
268
256
  .tag-imp { background: #e3f2fd; color: #1565c0; }
269
257
 
270
- /* 简介占据剩余空间 */
258
+ /* 修复:使用 Flex 自动填充剩余空间,并增加底部 padding 防止截断 */
271
259
  .summary-box {
272
- flex: 1; position: relative; margin-top: 4px; overflow: hidden;
260
+ flex: 1;
261
+ position: relative;
262
+ overflow: hidden;
263
+ min-height: 0;
273
264
  }
274
265
  .summary {
275
266
  font-size: 13px; color: #666; line-height: 1.6;
276
- display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
267
+ display: -webkit-box;
268
+ -webkit-line-clamp: 4;
269
+ -webkit-box-orient: vertical;
270
+ overflow: hidden;
271
+ padding-bottom: 3px; /* 关键修复:防止下行字母(如g,j,p,q,y)被切掉 */
277
272
  }
278
273
 
279
- /* 底部数据固定高度,防止被挤出 */
280
274
  .footer {
281
- border-top: 1px solid #eee; padding-top: 14px;
282
- display: flex; justify-content: space-between;
283
- font-size: 12px; color: #888; margin-top: 15px; flex-shrink: 0;
275
+ border-top: 1px solid #eee;
276
+ padding-top: 14px;
277
+ display: flex; justify-content: space-between;
278
+ font-size: 12px; color: #888;
279
+ margin-top: auto; /* 确保页脚始终在底部 */
280
+ flex-shrink: 0;
284
281
  }
285
282
  .stat b { color: #555; font-weight: bold; margin-right: 2px;}
286
283
  </style>
@@ -294,13 +291,8 @@ function apply(ctx, config) {
294
291
  ${subTitle ? `<div class="subtitle">${subTitle}</div>` : ""}
295
292
  <div class="author">@${info.UserName}</div>
296
293
  </div>
297
-
298
294
  <div class="tags">${displayTags.map((t) => `<span class="tag ${["文", "译", "R"].includes(t) ? "tag-imp" : ""}">${t}</span>`).join("")}</div>
299
-
300
- <div class="summary-box">
301
- <div class="summary">${summary}</div>
302
- </div>
303
-
295
+ <div class="summary-box"><div class="summary">${summary}</div></div>
304
296
  <div class="footer">
305
297
  <span class="stat"><b style="color:#009688">热度</b>${views}</span>
306
298
  <span class="stat"><b style="color:#673ab7">评论</b>${comments}</span>
@@ -360,7 +352,7 @@ function apply(ctx, config) {
360
352
  <div class="top-row"><div class="title">${r.title}</div><div class="id-badge">ID: ${r.id}</div></div>
361
353
  <div class="author">By ${r.author} ${r.status ? ` · ${r.status}` : ""}</div>
362
354
  <div class="tags">${r.tags.map((t) => `<span class="tag">${t}</span>`).join("")}</div>
363
- <div class="meta-row">${stats || "暂无数据"}</div>
355
+ <div class="meta-row">${stats || "No Data"}</div>
364
356
  </div></div>`;
365
357
  }).join("")}
366
358
  </div>
@@ -377,93 +369,105 @@ function apply(ctx, config) {
377
369
  }, "renderSearchResults");
378
370
  const renderReadPages = /* @__PURE__ */ __name(async (info) => {
379
371
  const content = cleanContent(info.Content);
372
+ const headerHeight = 40;
373
+ const footerHeight = 30;
374
+ const paddingX = 25;
375
+ const paddingY = 20;
376
+ const lineHeightRatio = 1.8;
377
+ const contentWidth = config.deviceWidth - paddingX * 2;
378
+ const columnGap = 40;
379
+ const maxContentHeight = config.deviceHeight - headerHeight - footerHeight;
380
+ const lineHeightPx = config.fontSize * lineHeightRatio;
381
+ const linesPerPage = Math.floor((maxContentHeight - paddingY * 2) / lineHeightPx);
382
+ const optimalContentHeight = linesPerPage * lineHeightPx + paddingY * 2;
383
+ const marginTop = Math.floor((maxContentHeight - optimalContentHeight) / 2) + headerHeight;
380
384
  const html = `
381
385
  <!DOCTYPE html>
382
386
  <html>
383
387
  <head>
384
388
  <style>
385
- body { margin: 0; padding: 0; width: ${config.deviceWidth}px; background-color: #f6f4ec; color: #2c2c2c; font-family: ${fontSerif}; }
389
+ body { margin: 0; padding: 0; width: ${config.deviceWidth}px; height: ${config.deviceHeight}px; background-color: #f6f4ec; color: #2c2c2c; font-family: ${fontSerif}; overflow: hidden; position: relative;}
386
390
 
387
- /* 页面容器:包含页眉、内容、页脚 */
388
- /* 固定高度,让 CSS Column 生效 */
389
- #content-wrapper {
390
- width: ${config.deviceWidth}px;
391
- height: ${config.deviceHeight}px;
392
- column-width: ${config.deviceWidth}px;
393
- column-gap: 0;
394
-
395
- /* 强制水平滚动 */
396
- overflow-y: hidden;
397
- overflow-x: scroll;
398
- }
399
-
400
- /* 每一页的实际内容区域 */
401
- /* 我们需要用 padding 来模拟页眉页脚的空间 */
402
- /* 上留 50px,下留 40px,左右 25px */
403
- .inner-content {
404
- padding: 50px 25px 40px 25px;
405
- box-sizing: border-box;
406
- height: 100%; /* 填满容器高度 */
407
-
408
- font-size: ${config.fontSize}px;
409
- line-height: 1.8;
410
- text-align: justify;
411
- }
412
-
413
- /* 固定位置的页眉页脚 (overlay) */
414
391
  .fixed-header {
415
- position: fixed; top: 0; left: 0; width: 100%; height: 40px;
416
- background: #f6f4ec; /* 遮挡背景 */
417
- z-index: 10;
418
- border-bottom: 1px solid #d7ccc8;
419
- display: flex; align-items: center; justify-content: space-between;
420
- padding: 0 20px; box-sizing: border-box;
421
- font-size: 12px; color: #8d6e63; font-weight: bold; font-family: ${fontStack};
392
+ position: absolute; top: 0; left: 0; width: 100%; height: ${headerHeight}px;
393
+ border-bottom: 1px solid #d7ccc8; box-sizing: border-box;
394
+ padding: 0 20px; display: flex; align-items: center; justify-content: space-between;
395
+ font-size: 12px; color: #8d6e63; background: #f6f4ec; z-index: 5; font-weight: bold;
422
396
  }
397
+
423
398
  .fixed-footer {
424
- position: fixed; bottom: 0; left: 0; width: 100%; height: 30px;
425
- background: #f6f4ec;
426
- z-index: 10;
427
- display: flex; align-items: center; justify-content: center;
428
- font-size: 12px; color: #aaa; font-family: ${fontStack};
399
+ position: absolute; bottom: 0; left: 0; width: 100%; height: ${footerHeight}px;
400
+ display: flex; align-items: center; justify-content: center;
401
+ font-size: 12px; color: #aaa; background: #f6f4ec; z-index: 5;
402
+ }
403
+
404
+ /* 视口容器:用于隐藏溢出的页面 */
405
+ #viewport {
406
+ position: absolute;
407
+ top: ${marginTop}px;
408
+ left: ${paddingX}px; /* 左边距 */
409
+ width: ${contentWidth}px; /* 限制视口只显示一页内容的宽度 */
410
+ height: ${optimalContentHeight}px;
411
+ overflow: hidden; /* 关键:隐藏其他页 */
429
412
  }
430
413
 
414
+ /* 内容长条:实际上是超宽的 */
415
+ #content-scroller {
416
+ height: 100%;
417
+ width: 100%; /* 这里的 100% 是相对于 viewport 的 contentWidth */
418
+
419
+ /* CSS Columns 布局 */
420
+ column-width: ${contentWidth}px;
421
+ column-gap: ${columnGap}px;
422
+ column-fill: auto;
423
+
424
+ padding: ${paddingY}px 0; /* 左右不需要 padding,因为 viewport 已经定了位置 */
425
+ box-sizing: border-box;
426
+
427
+ font-size: ${config.fontSize}px;
428
+ line-height: ${lineHeightRatio};
429
+ text-align: justify;
430
+
431
+ /* 关键:使用 transform 移动,初始位置为 0 */
432
+ transform: translateX(0);
433
+ transition: none;
434
+ }
435
+
431
436
  p { margin: 0 0 0.8em 0; text-indent: 2em; }
432
- /* 防止图片被切分 */
433
- 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); break-inside: avoid; }
434
- h1, h2, h3 { font-size: 1.2em; margin: 0.8em 0; color: #5d4037; text-indent: 0; font-weight: bold; break-after: avoid; }
437
+ img { max-width: 100%; height: auto; display: block; margin: 10px auto; border-radius: 6px; }
438
+ h1, h2, h3 { font-size: 1.1em; margin: 0.8em 0; color: #5d4037; text-indent: 0; font-weight: bold; break-after: avoid; break-inside: avoid; }
435
439
  </style>
436
440
  </head>
437
441
  <body>
438
- <div class="fixed-header">
439
- <span>${info.Title.substring(0, 12) + (info.Title.length > 12 ? "..." : "")}</span>
440
- <span>${info.UserName}</span>
441
- </div>
442
-
443
- <div id="content-wrapper">
444
- <div class="inner-content">
445
- ${content}
446
- </div>
442
+ <div class="fixed-header">
443
+ <span>${info.Title.substring(0, 12) + (info.Title.length > 12 ? "..." : "")}</span>
444
+ <span>${info.UserName}</span>
445
+ </div>
446
+
447
+ <div id="viewport">
448
+ <div id="content-scroller">
449
+ ${content}
447
450
  </div>
451
+ </div>
448
452
 
449
- <div class="fixed-footer" id="page-indicator">- 1 -</div>
453
+ <div class="fixed-footer" id="page-indicator">- 1 -</div>
450
454
  </body></html>`;
451
455
  const page = await ctx.puppeteer.page();
452
456
  try {
453
457
  await injectCookies(page);
454
458
  await page.setContent(html);
455
459
  await page.setViewport({ width: config.deviceWidth, height: config.deviceHeight, deviceScaleFactor: 2 });
456
- const scrollWidth = await page.$eval("#content-wrapper", (el) => el.scrollWidth);
457
- const totalPages = Math.ceil(scrollWidth / config.deviceWidth);
460
+ const scrollWidth = await page.$eval("#content-scroller", (el) => el.scrollWidth);
461
+ const step = contentWidth + columnGap;
462
+ const totalPages = Math.ceil((scrollWidth + columnGap) / step) || 1;
458
463
  const imgs = [];
459
464
  for (let i = 0; i < totalPages; i++) {
460
- await page.evaluate((x) => {
461
- document.getElementById("content-wrapper").scrollLeft = x;
462
- }, i * config.deviceWidth);
463
- await page.evaluate((curr, total) => {
465
+ await page.evaluate((idx, stepPx, curr, total) => {
466
+ const offset = -(idx * stepPx);
467
+ document.getElementById("content-scroller").style.transform = `translateX(${offset}px)`;
464
468
  document.getElementById("page-indicator").innerText = `- ${curr} / ${total} -`;
465
- }, i + 1, totalPages);
466
- const img = await page.screenshot({ type: "jpeg", quality: 100 });
469
+ }, i, step, i + 1, totalPages);
470
+ const img = await page.screenshot({ type: "jpeg", quality: 80 });
467
471
  imgs.push(img);
468
472
  }
469
473
  return imgs;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-fimtale-api",
3
3
  "description": "Koishi插件,从fimtale搜索/订阅/随机获取小说/解析链接等",
4
- "version": "0.0.9",
4
+ "version": "0.0.98",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [