koishi-plugin-booth-get 5.2.7 → 5.2.9

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 (3) hide show
  1. package/README.md +78 -8
  2. package/lib/index.js +374 -250
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,8 +1,78 @@
1
- 简单获取booth.pm页面的插件
2
- 适用用于“vrchat”“MMD模型”“周边”“游戏”“Live2D”“视频”
3
- 在使用vrchat的搜索时由于booth.pm的特殊性《指令摊:位搜索》会以最新发布商品为第一检索
4
- 默认情况下,插件会自动获取最新发布商品,如需获取其他商品,请自行修改插件代码
5
- ============================================================
6
- 后续更新会以卡片样式web版进行更新
7
- 优化更好且简介的样式面板
8
- ============================================================
1
+ # koishi-plugin-booth-get
2
+
3
+ - [NPM Package](https://www.npmjs.com/package/koishi-plugin-booth-get)
4
+ - [GitHub Repository](https://github.com/Unbloomed-flowers/koishi-plugin-booth-get)
5
+
6
+ ## 介绍
7
+
8
+ 简单获取 booth.pm 页面的插件。
9
+
10
+ ## 功能介绍
11
+
12
+ 本插件适用于获取以下类型内容:
13
+ - VRChat 相关商品
14
+ - MMD 模型
15
+ - 周边商品
16
+ - 游戏
17
+ - Live2D 资源
18
+ - 视频内容
19
+
20
+ ## 使用方法
21
+
22
+ ### 基础命令
23
+
24
+ 1. **获取指定商品信息**
25
+ 摊位 <商品ID>
26
+
27
+
28
+ 2. **搜索商品**
29
+ 摊位名称 <关键词> [-a <作者>]
30
+
31
+ 示例:
32
+ 摊位名称 模型 摊位名称 模型 -a 作者名
33
+
34
+
35
+ 3. **查看作者店铺**
36
+ 摊位作者 <作者名>
37
+
38
+
39
+ ### 订阅功能
40
+
41
+ 1. **订阅作者**
42
+ 摊位订阅 <作者名或链接>
43
+
44
+
45
+ 2. **取消订阅**
46
+ 摊位退订 <作者名或链接>
47
+
48
+
49
+ 3. **查看订阅列表**
50
+ 摊位订阅列表
51
+
52
+
53
+ ### 自动解析
54
+
55
+ 插件会自动识别消息中的 booth.pm 链接并生成商品或店铺卡片。
56
+
57
+ ## 特点说明
58
+
59
+ - 在使用 VRChat 相关搜索时,由于 booth.pm 的特殊性,《摊位名称》命令会以最新发布商品为第一检索结果。
60
+ - 默认情况下,插件会自动获取最新发布商品,如需获取其他商品,请自行修改插件代码。
61
+ - 后续更新会以卡片样式 web 版进行更新,优化更好且简介的样式面板。
62
+
63
+ ## 配置选项
64
+
65
+ - **加载超时时间**:设置页面加载的最长时间,默认为 10 秒
66
+ - **空闲超时时间**:等待页面空闲的最长时间,默认为 30 秒
67
+ - **代理服务器**:可配置代理服务器地址
68
+ - **R18 内容检测**:启用后会过滤包含 R18 标签的内容
69
+ - **更新检测间隔**:检测订阅作者更新的时间间隔(分钟),默认为 30 分钟
70
+ 这个更新后的 README.md 文件包含了以下改进:
71
+
72
+ 添加了清晰的标题和功能介绍
73
+ 详细说明了所有可用命令及其使用方法
74
+ 解释了插件的特殊功能,如自动链接解析和订阅系统
75
+ 补充了配置选项说明
76
+ 保持了原有的特点说明,但格式更加清晰
77
+ 使用了标准的 Markdown 格式,提高了可读性
78
+ 这样用户可以更容易理解插件的功能和使用方法。
package/lib/index.js CHANGED
@@ -26,24 +26,52 @@ __export(src_exports, {
26
26
  });
27
27
  module.exports = __toCommonJS(src_exports);
28
28
  var import_koishi = require("koishi");
29
- var QRCode = require("qrcode");
29
+ var fs = require("fs").promises;
30
+ var path = require("path");
30
31
 
31
32
  var name = "booth-get";
32
33
  var inject = ["puppeteer"];
33
34
  var Config = import_koishi.Schema.object({
34
35
  loadTimeout: import_koishi.Schema.natural().role("ms").description("加载页面的最长时间").default(import_koishi.Time.second * 10),
35
36
  idleTimeout: import_koishi.Schema.natural().role("ms").description("等待页面空闲的最长时间").default(import_koishi.Time.second * 30),
36
- proxyServer: import_booth.Schema.string().description("代理服务器地址").default("61.216.156.222:60808"),
37
+ proxyServer: import_koishi.Schema.string().description("代理服务器地址").default("61.216.156.222:60808"),
37
38
  enableR18Check: import_koishi.Schema.boolean().description("启用R18内容检测").default(true),
38
- r18Tags: import_koishi.Schema.array(String).description("R18标签列表").default(["R-18", "R18", "成人向け", "NSFW", "18禁"])
39
-
39
+ r18Tags: import_koishi.Schema.array(import_koishi.Schema.string()).description("R18标签").default(["r18", "18禁", "R-18", "R18+", "R-18+", "R18G", "R-18G", "R18G+", "R-18G+", "R18G++", "R-18G++", "R18G+++", "R-18G+++", "R18G++++", "R-18G++++",]),
40
+ updateInterval: import_koishi.Schema.natural().description("检测订阅更新间隔(分钟)").default(30)
40
41
  }).description("booth-get");
41
42
 
42
- // R18内容检测函数
43
+ const SUBS_FILE = path.join(__dirname, "subscriptions.json");
44
+ const AUTHOR_ITEMS_FILE = path.join(__dirname, "author_items.json");
45
+
46
+ async function loadJSON(file, def = {}) {
47
+ try {
48
+ const raw = await fs.readFile(file, "utf8");
49
+ return JSON.parse(raw);
50
+ } catch (e) {
51
+ return def;
52
+ }
53
+ }
54
+
55
+ async function saveJSON(file, data) {
56
+ await fs.mkdir(path.dirname(file), { recursive: true }).catch(() => {});
57
+ await fs.writeFile(file, JSON.stringify(data, null, 2), "utf8");
58
+ }
59
+
60
+ function normalizeBoothUrl(target) {
61
+ let url = target.trim();
62
+ if (!/^https?:\/\//i.test(url)) {
63
+ if (!url.includes(".booth.pm")) {
64
+ url = `https://${url}.booth.pm`;
65
+ } else {
66
+ url = `https://${url}`.replace(/^https?:\/\//i, "");
67
+ }
68
+ }
69
+ return url.replace(/\/+$/,'');
70
+ }
71
+
43
72
  function checkR18(item, config) {
44
73
  if (!config.enableR18Check) return false;
45
74
 
46
- // 检查标签
47
75
  if (item.tags && Array.isArray(item.tags)) {
48
76
  const hasR18Tag = item.tags.some(tag =>
49
77
  config.r18Tags.some(r18Tag =>
@@ -53,15 +81,13 @@ function checkR18(item, config) {
53
81
  if (hasR18Tag) return true;
54
82
  }
55
83
 
56
- // 检查标题
57
84
  if (item.title) {
58
85
  const hasR18InTitle = config.r18Tags.some(r18Tag =>
59
86
  item.title.toLowerCase().includes(r18Tag.toLowerCase())
60
87
  );
61
88
  if (hasR18InTitle) return true;
62
89
  }
63
-
64
- // 检查描述
90
+
65
91
  if (item.description) {
66
92
  const hasR18InDesc = config.r18Tags.some(r18Tag =>
67
93
  item.description.toLowerCase().includes(r18Tag.toLowerCase())
@@ -343,10 +369,10 @@ function generateCardHTML(item, relatedItems = []) {
343
369
  </div>
344
370
 
345
371
  <div class="description">
346
- <p>${item.description.slice(0, 300)}${item.description.length > 300 ? '...' : ''}</p>
372
+ <p>${(item.description || "").slice(0, 300)}${(item.description||"").length > 300 ? '...' : ''}</p>
347
373
  </div>
348
374
 
349
- ${relatedItems.length > 0 ? `
375
+ ${relatedItems && relatedItems.length > 0 ? `
350
376
  <div class="related-works">
351
377
  <h3 class="related-title">同じ作者の作品</h3>
352
378
  <div class="works-grid">
@@ -355,7 +381,7 @@ function generateCardHTML(item, relatedItems = []) {
355
381
  <div class="work-image" style="background-image:url('${work.image_url}')"></div>
356
382
  <div class="work-info">
357
383
  <div class="work-title">${work.title.slice(0, 20)}${work.title.length > 20 ? '...' : ''}</div>
358
- <div class="work-price">¥${work.price.toLocaleString()}</div>
384
+ <div class="work-price">¥${work.price?.toLocaleString?.() ?? work.price}</div>
359
385
  </div>
360
386
  </div>
361
387
  `).join('')}
@@ -390,14 +416,14 @@ async function getBoothItem(id) {
390
416
  id,
391
417
  title: itemData.name,
392
418
  price: itemData.price,
393
- image_url: itemData.images[0]?.original,
394
- description: itemData.description,
395
- category: itemData.category?.name,
396
- parent_category: itemData.category?.parent?.name,
397
- author: itemData.shop?.name,
398
- author_thumbnail_url: itemData.shop?.thumbnail_url,
399
- likes: wishData.wishlists_counts[id] || 0,
400
- tags: itemData.tags
419
+ image_url: itemData.images?.[0]?.original || null,
420
+ description: itemData.description || "",
421
+ category: itemData.category?.name || "",
422
+ parent_category: itemData.category?.parent?.name || "",
423
+ author: itemData.shop?.name || "",
424
+ author_thumbnail_url: itemData.shop?.thumbnail_url || "",
425
+ likes: (wishData && wishData.wishlists_counts && wishData.wishlists_counts[id]) || 0,
426
+ tags: itemData.tags || []
401
427
  };
402
428
  } catch (error) {
403
429
  return null;
@@ -408,14 +434,14 @@ async function fetchRelatedItems(author) {
408
434
  try {
409
435
  const res = await fetch(`https://booth.pm/zh-cn/search.json?q=${encodeURIComponent(author)}&in_stock=true`);
410
436
  const data = await res.json();
411
- return data.items
437
+ return (data.items || [])
412
438
  .filter(i => i.shop?.name === author)
413
439
  .slice(0, 3)
414
440
  .map(item => ({
415
441
  id: item.id,
416
442
  title: item.name,
417
443
  price: item.price,
418
- image_url: item.images[0]?.original,
444
+ image_url: item.images?.[0]?.original
419
445
  }));
420
446
  } catch (error) {
421
447
  return [];
@@ -427,7 +453,6 @@ async function captureCard(ctx, id, config) {
427
453
  const item = await getBoothItem(id);
428
454
  if (!item) return null;
429
455
 
430
- // R18内容检测
431
456
  if (checkR18(item, config)) {
432
457
  logger.warn(`检测到R18内容,已跳过商品: ${id}`);
433
458
  return "R18_CONTENT";
@@ -447,10 +472,10 @@ async function captureCard(ctx, id, config) {
447
472
  timeout: config.loadTimeout || import_koishi.Time.second * 10
448
473
  });
449
474
 
450
- await new Promise(resolve => setTimeout(resolve, 2000));
475
+ await new Promise(resolve => setTimeout(resolve, 1200));
451
476
 
452
477
  await page.setViewport({ width: 640, height: 1200 });
453
- const container = await page.$('.container');
478
+ const container = await page.$('.container') || await page.$('body');
454
479
  return await container.screenshot({
455
480
  type: 'png',
456
481
  encoding: 'binary',
@@ -464,168 +489,7 @@ async function captureCard(ctx, id, config) {
464
489
  }
465
490
  }
466
491
 
467
- function apply(ctx, config) {
468
- const logger = ctx.logger("booth-get");
469
-
470
- ctx.command("摊位 <id>")
471
- .action(async ({ session }, id) => {
472
- if (!id) return "请输入商品ID";
473
- try {
474
- const buffer = await captureCard(ctx, id, config);
475
- if (buffer === "R18_CONTENT") return "该商品可能包含R18内容,已跳过";
476
- return buffer ? import_koishi.h.image(buffer, "image/png") : "商品获取失败";
477
- } catch (error) {
478
- logger.warn(error);
479
- return "卡片生成失败";
480
- }
481
- });
482
-
483
- ctx.command("摊位名称 <query:text>")
484
- .option('author', '-a <author> 指定作者名称')
485
- .action(async ({ session, options }, query) => {
486
- if (!query) return "请输入搜索关键词";
487
-
488
- let searchQuery = query;
489
- let authorFilter = options.author;
490
-
491
- if (!authorFilter && query.includes(' ')) {
492
- const parts = query.split(' ');
493
- if (parts.length >= 2) {
494
- authorFilter = parts.pop();
495
- searchQuery = parts.join(' ');
496
- }
497
- }
498
-
499
- let searchUrl = `https://booth.pm/zh-cn/search/${encodeURIComponent(searchQuery)}?in_stock=true`;
500
-
501
- const tags = ['3Dモデル', 'Vrchat'];
502
- const tagsParams = tags.map(tag => `tags[]=${encodeURIComponent(tag)}`).join('&');
503
- searchUrl += `&${tagsParams}&min_price=4500`;
504
-
505
- const page = await ctx.puppeteer.page();
506
-
507
- await page.setRequestInterception(true);
508
- page.on('request', (request) => {
509
- const resourceType = request.resourceType();
510
- if (['image', 'stylesheet', 'font'].includes(resourceType)) {
511
- request.abort();
512
- } else {
513
- request.continue();
514
- }
515
- });
516
-
517
- try {
518
- let retries = 3;
519
- while (retries > 0) {
520
- try {
521
- await page.goto(searchUrl, { waitUntil: 'networkidle0', timeout: config.loadTimeout || import_koishi.Time.second * 10 });
522
- break;
523
- } catch (error) {
524
- retries--;
525
- if (retries === 0) throw error;
526
- logger.warn(`页面加载失败,重试中... (剩余重试次数: ${retries})`);
527
- await new Promise(resolve => setTimeout(resolve, 2000));
528
- }
529
- }
530
-
531
- const content = await page.content();
532
-
533
- const itemRegex = /item-card__wrap"[\s\S]*?id="item_(\d+)"[\s\S]*?<h2[^>]*class="[^"]*item-card__title[^"]*"[^>]*>([^<]+)<\/h2>/g;
534
- let matches = [];
535
- let match;
536
-
537
- while ((match = itemRegex.exec(content)) !== null) {
538
- matches.push({
539
- id: match[1],
540
- title: match[2].trim()
541
- });
542
- }
543
-
544
- if (matches.length === 0) {
545
- const simpleRegex = /item-card__wrap"[\s\S]*?id="item_(\d+)"/g;
546
- let simpleMatch;
547
- while ((simpleMatch = simpleRegex.exec(content)) !== null) {
548
- matches.push({
549
- id: simpleMatch[1],
550
- title: '未知商品'
551
- });
552
- }
553
- }
554
-
555
- if (matches.length === 0) {
556
- if (content.includes('検索結果はありません') ||
557
- content.includes('没有找到') ||
558
- content.includes('検索条件に合致する作品は見つかりませんでした') ||
559
- content.includes('該当する作品はありません')) {
560
- return "没有找到相关商品";
561
- }
562
- return "没有找到相关商品";
563
- }
564
-
565
- let selectedItemId;
566
- if (authorFilter) {
567
- for (const item of matches) {
568
- try {
569
- const itemDetail = await getBoothItem(item.id);
570
- if (itemDetail && itemDetail.author &&
571
- itemDetail.author.toLowerCase().includes(authorFilter.toLowerCase())) {
572
- selectedItemId = item.id;
573
- break;
574
- }
575
- } catch (err) {
576
- continue;
577
- }
578
- }
579
-
580
- if (!selectedItemId) {
581
- return `找不到作者"${authorFilter}"的相关商品`;
582
- }
583
- } else {
584
- selectedItemId = matches[0].id;
585
- }
586
-
587
- try {
588
- const buffer = await captureCard(ctx, selectedItemId, config);
589
- if (buffer === "R18_CONTENT") {
590
- return "搜索到的商品可能包含R18内容,已跳过";
591
- }
592
- if (!buffer) {
593
- return "卡片生成失败";
594
- }
595
- return import_koishi.h.image(buffer, "image/png");
596
- } catch (error) {
597
- logger.error('卡片生成失败:', error);
598
- return "卡片生成失败";
599
- }
600
- } catch (error) {
601
- logger.error('搜索失败:', error);
602
- if (error.message.includes('ERR_EMPTY_RESPONSE') || error.message.includes('net::ERR_CONNECTION_TIMED_OUT')) {
603
- return "搜索失败,连接BOOTH网站超时,请稍后再试";
604
- }
605
- return "搜索失败";
606
- } finally {
607
- await page.close();
608
- }
609
- });
610
-
611
- function getSimilarity(a, b) {
612
- if (a === b) return 1;
613
- if (a.length < 2 || b.length < 2) return 0;
614
-
615
- const bigramsA = new Set();
616
- for (let i = 0; i < a.length - 1; i++) {
617
- bigramsA.add(a.substring(i, i + 2));
618
- }
619
-
620
- let matches = 0;
621
- for (let i = 0; i < b.length - 1; i++) {
622
- if (bigramsA.has(b.substring(i, i + 2))) matches++;
623
- }
624
-
625
- return (2 * matches) / (a.length + b.length - 2);
626
- }
627
-
628
- async function fetchAuthorItems(ctx, authorName, limit = 6) {
492
+ async function fetchAuthorItems(ctx, authorName, limit = 6, configParam) {
629
493
  try {
630
494
  const page = await ctx.puppeteer.page();
631
495
 
@@ -642,20 +506,16 @@ async function fetchAuthorItems(ctx, authorName, limit = 6) {
642
506
 
643
507
  await page.goto(`https://${authorName}.booth.pm/items`, {
644
508
  waitUntil: 'networkidle0',
645
- timeout: config.loadTimeout || import_koishi.Time.second * 10
646
- });
647
- await page.waitForSelector('.item-list', { timeout: 5000 });
509
+ timeout: (configParam?.loadTimeout || import_koishi.Time.second * 10)
510
+ }).catch(()=>{});
511
+ await page.waitForSelector('.item-list', { timeout: 5000 }).catch(() => {});
648
512
 
649
513
  const items = await page.evaluate((limit) => {
650
514
  const itemElements = Array.from(document.querySelectorAll('.js-mount-point-shop-item-card'));
651
515
  return itemElements.slice(0, limit).map(el => {
652
516
  try {
653
517
  const dataItem = JSON.parse(el.getAttribute('data-item'));
654
- const imageUrl = dataItem.thumbnail_image_urls?.[0] ||
655
- dataItem.images?.[0]?.original ||
656
- el.querySelector('.swap-image img')?.src ||
657
- 'https://s2.booth.pm/static-images/item/empty-preview.png';
658
-
518
+ const imageUrl = dataItem.thumbnail_image_urls?.[0] || dataItem.images?.[0]?.original || el.querySelector('.swap-image img')?.src || 'https://s2.booth.pm/static-images/item/empty-preview.png';
659
519
  let price = dataItem.price;
660
520
  if (typeof price === 'string') {
661
521
  const priceMatch = price.match(/[\d,]+/);
@@ -665,7 +525,6 @@ async function fetchAuthorItems(ctx, authorName, limit = 6) {
665
525
  price = 0;
666
526
  }
667
527
  }
668
-
669
528
  return {
670
529
  id: dataItem.id,
671
530
  title: dataItem.name,
@@ -679,7 +538,6 @@ async function fetchAuthorItems(ctx, authorName, limit = 6) {
679
538
  const titleEl = el.querySelector('.item-name a');
680
539
  const priceEl = el.querySelector('.price');
681
540
  const imgEl = el.querySelector('.swap-image img');
682
-
683
541
  let price = 0;
684
542
  if (priceEl) {
685
543
  const priceText = priceEl.textContent.trim();
@@ -688,7 +546,6 @@ async function fetchAuthorItems(ctx, authorName, limit = 6) {
688
546
  price = parseInt(priceMatch[0].replace(/,/g, '')) || 0;
689
547
  }
690
548
  }
691
-
692
549
  return {
693
550
  id: null,
694
551
  title: titleEl ? titleEl.textContent.trim() : '未知商品',
@@ -875,7 +732,7 @@ function generateAuthorShopCardHTML(authorName, items = []) {
875
732
  <div class="item-image" style="background-image:url('${item.image_url}')"></div>
876
733
  <div class="item-info">
877
734
  <div class="item-title">${item.title.slice(0, 25)}${item.title.length > 25 ? '...' : ''}</div>
878
- <div class="item-price">¥${item.price.toLocaleString()}</div>
735
+ <div class="item-price">¥${item.price?.toLocaleString?.() ?? item.price}</div>
879
736
  </div>
880
737
  </div>
881
738
  `).join('')}
@@ -900,9 +757,9 @@ function generateAuthorShopCardHTML(authorName, items = []) {
900
757
  </html>`;
901
758
  }
902
759
 
903
- async function captureAuthorShopCard(ctx, authorName) {
760
+ async function captureAuthorShopCard(ctx, authorName, config) {
904
761
  const logger = ctx.logger("booth-get");
905
- const items = await fetchAuthorItems(ctx, authorName);
762
+ const items = await fetchAuthorItems(ctx, authorName, 6, config);
906
763
 
907
764
  const html = generateAuthorShopCardHTML(authorName, items);
908
765
 
@@ -916,10 +773,10 @@ async function captureAuthorShopCard(ctx, authorName) {
916
773
  timeout: config.loadTimeout || import_koishi.Time.second * 10
917
774
  });
918
775
 
919
- await new Promise(resolve => setTimeout(resolve, 2000));
776
+ await new Promise(resolve => setTimeout(resolve, 1200));
920
777
 
921
778
  await page.setViewport({ width: 640, height: 1200 });
922
- const container = await page.$('.container');
779
+ const container = await page.$('.container') || await page.$('body');
923
780
  return await container.screenshot({
924
781
  type: 'png',
925
782
  encoding: 'binary',
@@ -932,63 +789,166 @@ async function captureAuthorShopCard(ctx, authorName) {
932
789
  await page.close();
933
790
  }
934
791
  }
935
- ctx.middleware(async (session, next) => {
936
- const boothUrlRegex = /https:\/\/booth.pm\/[\w-]+\/items\/(\d+)/;
937
- const boothAuthorUrlRegex = /https:\/\/([\w-]+)\.booth\.pm\/items(?:\/(\d+))?/;
938
- const match = session.content.match(boothUrlRegex);
939
- const authorMatch = session.content.match(boothAuthorUrlRegex);
940
792
 
941
- if (match) {
942
- const itemId = match[1];
793
+ function getSimilarity(a, b) {
794
+ if (a === b) return 1;
795
+ if (a.length < 2 || b.length < 2) return 0;
796
+ const bigramsA = new Set();
797
+ for (let i = 0; i < a.length - 1; i++) bigramsA.add(a.substring(i, i + 2));
798
+ let matches = 0;
799
+ for (let i = 0; i < b.length - 1; i++) if (bigramsA.has(b.substring(i, i + 2))) matches++;
800
+ return (2 * matches) / (a.length + b.length - 2);
801
+ }
802
+
803
+ function apply(ctx, config) {
804
+ const logger = ctx.logger("booth-get");
805
+
806
+ ctx.command("摊位 <id>")
807
+ .action(async ({ session }, id) => {
808
+ if (!id) return "请输入商品ID";
943
809
  try {
944
- const buffer = await captureCard(ctx, itemId, config);
945
- if (buffer === "R18_CONTENT") {
946
- await session.send("该商品可能包含R18内容,已跳过");
947
- return "";
810
+ const buffer = await captureCard(ctx, id, config);
811
+ if (buffer === "R18_CONTENT") return "该商品可能包含R18内容,已跳过";
812
+ return buffer ? import_koishi.h.image(buffer, "image/png") : "商品获取失败";
813
+ } catch (error) {
814
+ logger.warn(error);
815
+ return "卡片生成失败";
816
+ }
817
+ });
818
+
819
+ ctx.command("摊位名称 <query:text>")
820
+ .option('author', '-a <author> 指定作者名称')
821
+ .action(async ({ session, options }, query) => {
822
+ if (!query) return "请输入搜索关键词";
823
+
824
+ let searchQuery = query;
825
+ let authorFilter = options.author;
826
+
827
+ if (!authorFilter && query.includes(' ')) {
828
+ const parts = query.split(' ');
829
+ if (parts.length >= 2) {
830
+ authorFilter = parts.pop();
831
+ searchQuery = parts.join(' ');
948
832
  }
949
- if (buffer) {
950
- await session.send(import_koishi.h.image(buffer, "image/png"));
951
- return "";
833
+ }
834
+
835
+ let searchUrl = `https://booth.pm/zh-cn/search/${encodeURIComponent(searchQuery)}?in_stock=true`;
836
+
837
+ const tags = ['3Dモデル', 'Vrchat'];
838
+ const tagsParams = tags.map(tag => `tags[]=${encodeURIComponent(tag)}`).join('&');
839
+ searchUrl += `&${tagsParams}&min_price=4500`;
840
+
841
+ const page = await ctx.puppeteer.page();
842
+
843
+ await page.setRequestInterception(true);
844
+ page.on('request', (request) => {
845
+ const resourceType = request.resourceType();
846
+ if (['image', 'stylesheet', 'font'].includes(resourceType)) {
847
+ request.abort();
952
848
  } else {
953
- return "商品解析失败";
849
+ request.continue();
954
850
  }
955
- } catch (error) {
956
- logger.warn("链接解析失败:", error);
957
- return "商品解析失败";
958
- }
959
- } else if (authorMatch) {
960
- const authorName = authorMatch[1];
961
- const itemId = authorMatch[2];
851
+ });
962
852
 
963
853
  try {
964
- if (itemId) {
965
- const buffer = await captureCard(ctx, itemId, config);
966
- if (buffer === "R18_CONTENT") {
967
- await session.send("该商品可能包含R18内容,已跳过");
968
- return "";
854
+ let retries = 3;
855
+ while (retries > 0) {
856
+ try {
857
+ await page.goto(searchUrl, { waitUntil: 'networkidle0', timeout: config.loadTimeout || import_koishi.Time.second * 10 });
858
+ break;
859
+ } catch (error) {
860
+ retries--;
861
+ if (retries === 0) throw error;
862
+ logger.warn(`页面加载失败,重试中... (剩余重试次数: ${retries})`);
863
+ await new Promise(resolve => setTimeout(resolve, 2000));
969
864
  }
970
- if (buffer) {
971
- await session.send(import_koishi.h.image(buffer, "image/png"));
972
- return "";
973
- } else {
974
- return "商品解析失败";
865
+ }
866
+
867
+ const content = await page.content();
868
+
869
+ const itemRegex = /item-card__wrap"[\s\S]*?id="item_(\d+)"[\s\S]*?<h2[^>]*class="[^"]*item-card__title[^"]*"[^>]*>([^<]+)<\/h2>/g;
870
+ let matches = [];
871
+ let match;
872
+
873
+ while ((match = itemRegex.exec(content)) !== null) {
874
+ matches.push({
875
+ id: match[1],
876
+ title: match[2].trim()
877
+ });
878
+ }
879
+
880
+ if (matches.length === 0) {
881
+ const simpleRegex = /item-card__wrap"[\s\S]*?id="item_(\d+)"/g;
882
+ let simpleMatch;
883
+ while ((simpleMatch = simpleRegex.exec(content)) !== null) {
884
+ matches.push({
885
+ id: simpleMatch[1],
886
+ title: '未知商品'
887
+ });
888
+ }
889
+ }
890
+
891
+ if (matches.length === 0) {
892
+ if (content.includes('検索結果はありません') ||
893
+ content.includes('没有找到') ||
894
+ content.includes('検索条件に合致する作品は見つかりませんでした') ||
895
+ content.includes('該当する作品はありません')) {
896
+ await page.close();
897
+ return "没有找到相关商品";
898
+ }
899
+ await page.close();
900
+ return "没有找到相关商品";
901
+ }
902
+
903
+ let selectedItemId;
904
+ if (authorFilter) {
905
+ for (const item of matches) {
906
+ try {
907
+ const itemDetail = await getBoothItem(item.id);
908
+ if (itemDetail && itemDetail.author &&
909
+ itemDetail.author.toLowerCase().includes(authorFilter.toLowerCase())) {
910
+ selectedItemId = item.id;
911
+ break;
912
+ }
913
+ } catch (err) {
914
+ continue;
915
+ }
916
+ }
917
+
918
+ if (!selectedItemId) {
919
+ await page.close();
920
+ return `找不到作者"${authorFilter}"的相关商品`;
975
921
  }
976
922
  } else {
977
- const buffer = await captureAuthorShopCard(ctx, authorName);
978
- if (buffer) {
979
- await session.send(import_koishi.h.image(buffer, "image/png"));
980
- return "";
981
- } else {
982
- return "作者店铺卡片生成失败";
923
+ selectedItemId = matches[0].id;
924
+ }
925
+
926
+ try {
927
+ const buffer = await captureCard(ctx, selectedItemId, config);
928
+ if (buffer === "R18_CONTENT") {
929
+ await page.close();
930
+ return "搜索到的商品可能包含R18内容,已跳过";
983
931
  }
932
+ if (!buffer) {
933
+ await page.close();
934
+ return "卡片生成失败";
935
+ }
936
+ await page.close();
937
+ return import_koishi.h.image(buffer, "image/png");
938
+ } catch (error) {
939
+ logger.error('卡片生成失败:', error);
940
+ await page.close();
941
+ return "卡片生成失败";
984
942
  }
985
943
  } catch (error) {
986
- logger.warn("作者链接解析失败:", error);
987
- return "作者链接解析失败";
944
+ logger.error('搜索失败:', error);
945
+ await page.close();
946
+ if (error.message && (error.message.includes('ERR_EMPTY_RESPONSE') || error.message.includes('net::ERR_CONNECTION_TIMED_OUT'))) {
947
+ return "搜索失败,连接BOOTH网站超时,请稍后再试";
948
+ }
949
+ return "搜索失败";
988
950
  }
989
- }
990
- return next();
991
- });
951
+ });
992
952
 
993
953
  ctx.command("摊位作者 <authorName:text>")
994
954
  .action(async ({ session }, authorName) => {
@@ -1023,9 +983,11 @@ async function captureAuthorShopCard(ctx, authorName) {
1023
983
 
1024
984
  const brandArray = Array.from(brands);
1025
985
 
986
+ await page.close();
987
+
1026
988
  if (brandArray.length > 0) {
1027
989
  const matchedAuthor = brandArray[0];
1028
- const buffer = await captureAuthorShopCard(ctx, matchedAuthor);
990
+ const buffer = await captureAuthorShopCard(ctx, matchedAuthor, config);
1029
991
  if (buffer) {
1030
992
  return import_koishi.h.image(buffer, "image/png");
1031
993
  } else {
@@ -1035,16 +997,178 @@ async function captureAuthorShopCard(ctx, authorName) {
1035
997
  return `未找到作者 "${authorName}" 的店铺`;
1036
998
  }
1037
999
  } catch (error) {
1000
+ await page.close();
1038
1001
  logger.error('搜索作者失败:', error);
1039
1002
  return "搜索作者失败";
1040
- } finally {
1041
- await page.close();
1042
1003
  }
1043
1004
  } catch (error) {
1044
1005
  logger.error('处理作者搜索失败:', error);
1045
1006
  return "处理作者搜索失败";
1046
1007
  }
1047
1008
  });
1009
+
1010
+ ctx.middleware(async (session, next) => {
1011
+ const boothUrlRegex = /https:\/\/booth.pm\/[\w-]+\/items\/(\d+)/;
1012
+ const boothAuthorUrlRegex = /https:\/\/([\w-]+)\.booth\.pm(?:\/items(?:\/\d+)?)?/;
1013
+ const match = session.content.match(boothUrlRegex);
1014
+ const authorMatch = session.content.match(boothAuthorUrlRegex);
1015
+
1016
+ if (match) {
1017
+ const itemId = match[1];
1018
+ try {
1019
+ const buffer = await captureCard(ctx, itemId, config);
1020
+ if (buffer === "R18_CONTENT") {
1021
+ await session.send("该商品可能包含R18内容,已跳过");
1022
+ return "";
1023
+ }
1024
+ if (buffer) {
1025
+ await session.send(import_koishi.h.image(buffer, "image/png"));
1026
+ return "";
1027
+ } else {
1028
+ return "商品解析失败";
1029
+ }
1030
+ } catch (error) {
1031
+ logger.warn("链接解析失败:", error);
1032
+ return "商品解析失败";
1033
+ }
1034
+ } else if (authorMatch) {
1035
+ const authorName = authorMatch[1];
1036
+ const itemId = (authorMatch[0].match(/\/items\/(\d+)/) || [])[1];
1037
+ try {
1038
+ if (itemId) {
1039
+ const buffer = await captureCard(ctx, itemId, config);
1040
+ if (buffer === "R18_CONTENT") {
1041
+ await session.send("该商品可能包含R18内容,已跳过");
1042
+ return "";
1043
+ }
1044
+ if (buffer) {
1045
+ await session.send(import_koishi.h.image(buffer, "image/png"));
1046
+ return "";
1047
+ } else {
1048
+ return "商品解析失败";
1049
+ }
1050
+ } else {
1051
+ const buffer = await captureAuthorShopCard(ctx, authorName, config);
1052
+ if (buffer) {
1053
+ await session.send(import_koishi.h.image(buffer, "image/png"));
1054
+ return "";
1055
+ } else {
1056
+ return "作者店铺卡片生成失败";
1057
+ }
1058
+ }
1059
+ } catch (error) {
1060
+ logger.warn("作者链接解析失败:", error);
1061
+ return "作者链接解析失败";
1062
+ }
1063
+ }
1064
+ return next();
1065
+ });
1066
+
1067
+ ctx.command("摊位订阅 <target>")
1068
+ .action(async ({ session }, target) => {
1069
+ if (!target) return "请输入作者名或 Booth 链接";
1070
+ const url = normalizeBoothUrl(target);
1071
+ const userKey = `user:${session.platform}:${session.userId}`;
1072
+ const subs = await loadJSON(SUBS_FILE);
1073
+ subs[userKey] = subs[userKey] || [];
1074
+ if (!subs[userKey].includes(url)) {
1075
+ subs[userKey].push(url);
1076
+ await saveJSON(SUBS_FILE, subs);
1077
+ return `✅ 已订阅:${url}`;
1078
+ } else {
1079
+ return `⚠️ 你已经订阅过 ${url} 了`;
1080
+ }
1081
+ });
1082
+
1083
+ ctx.command("摊位退订 <target>")
1084
+ .action(async ({ session }, target) => {
1085
+ if (!target) return "请输入作者名或 Booth 链接";
1086
+ const url = normalizeBoothUrl(target);
1087
+ const userKey = `user:${session.platform}:${session.userId}`;
1088
+ const subs = await loadJSON(SUBS_FILE);
1089
+ if (!subs[userKey] || subs[userKey].length === 0) return "⚠️ 你还没有订阅任何作者";
1090
+ subs[userKey] = subs[userKey].filter(u => u !== url);
1091
+ await saveJSON(SUBS_FILE, subs);
1092
+ return `❌ 已取消订阅:${url}`;
1093
+ });
1094
+
1095
+ ctx.command("摊位订阅列表")
1096
+ .action(async ({ session }) => {
1097
+ const userKey = `user:${session.platform}:${session.userId}`;
1098
+ const subs = await loadJSON(SUBS_FILE);
1099
+ if (!subs[userKey] || subs[userKey].length === 0) return "📭 你还没有订阅任何作者";
1100
+ return `📌 你订阅的作者有:\n${subs[userKey].join("\n")}`;
1101
+ });
1102
+
1103
+ async function notifySubscribers(authorUrl, newItems) {
1104
+ const subs = await loadJSON(SUBS_FILE);
1105
+ const userKeys = Object.keys(subs).filter(k => (subs[k] || []).includes(authorUrl));
1106
+ if (userKeys.length === 0) return;
1107
+ for (const userKey of userKeys) {
1108
+ const parts = userKey.split(':');
1109
+ if (parts.length < 3) continue;
1110
+ const platform = parts[1];
1111
+ const userId = parts.slice(2).join(':');
1112
+ const bots = Object.values(ctx.bots || {});
1113
+ for (const bot of bots) {
1114
+ try {
1115
+ for (const it of newItems) {
1116
+ try {
1117
+ const buffer = await captureCard(ctx, it.id, config);
1118
+ if (buffer === "R18_CONTENT") continue;
1119
+ const text = `🆕 作者 ${authorUrl} 发布了新商品:${it.title}\n商品链接:https://booth.pm/zh-cn/items/${it.id}`;
1120
+ if (typeof bot.sendPrivateMessage === 'function') {
1121
+ await bot.sendPrivateMessage(userId, [text, import_koishi.h.image(buffer, "image/png")]);
1122
+ break;
1123
+ }
1124
+ if (typeof bot.sendMessage === 'function') {
1125
+ await bot.sendMessage(userId, [text, import_koishi.h.image(buffer, "image/png")]);
1126
+ break;
1127
+ }
1128
+ if (typeof bot.send === 'function') {
1129
+ await bot.send(userId, [text, import_koishi.h.image(buffer, "image/png")]);
1130
+ break;
1131
+ }
1132
+ } catch (e) {
1133
+ // try next bot/fallback
1134
+ }
1135
+ }
1136
+ } catch (e) {
1137
+ logger.warn("推送订阅消息失败:", e);
1138
+ }
1139
+ }
1140
+ }
1141
+ }
1142
+
1143
+ ctx.setInterval(async () => {
1144
+ const subs = await loadJSON(SUBS_FILE);
1145
+ const authorItems = await loadJSON(AUTHOR_ITEMS_FILE);
1146
+ const checkedAuthors = new Set();
1147
+ for (const userKey in subs) {
1148
+ for (const authorUrl of subs[userKey]) {
1149
+ if (!authorUrl) continue;
1150
+ if (checkedAuthors.has(authorUrl)) continue;
1151
+ checkedAuthors.add(authorUrl);
1152
+ try {
1153
+ const m = authorUrl.match(/https?:\/\/([^./]+)\.booth\.pm/i);
1154
+ if (!m) continue;
1155
+ const authorName = m[1];
1156
+ const items = await fetchAuthorItems(ctx, authorName, 6, config);
1157
+ const latestIds = items.map(i => i.id).filter(Boolean);
1158
+ const oldIds = authorItems[authorUrl] || [];
1159
+ const newIds = latestIds.filter(id => !oldIds.includes(id));
1160
+ if (newIds.length > 0) {
1161
+ const newItems = items.filter(i => newIds.includes(i.id));
1162
+ authorItems[authorUrl] = latestIds;
1163
+ await saveJSON(AUTHOR_ITEMS_FILE, authorItems);
1164
+ await notifySubscribers(authorUrl, newItems);
1165
+ }
1166
+ } catch (e) {
1167
+ logger.warn("检测作者新作失败:", e);
1168
+ }
1169
+ }
1170
+ }
1171
+ }, (config.updateInterval || 30) * 60 * 1000);
1048
1172
  }
1049
1173
 
1050
- __name(apply, "apply");
1174
+ __name(apply, "apply");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-booth-get",
3
3
  "description": "通过url与名称检查摊位物品并反馈用户搜索的图片",
4
- "version": "5.2.7",
4
+ "version": "5.2.9",
5
5
  "contributors": [
6
6
  "rixiang <1148147857@qq.com>"
7
7
  ],