koishi-plugin-booth-get 6.0.0-beta.3 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -315,226 +315,227 @@ class CardGenerator {
315
315
  </html>`;
316
316
  }
317
317
 
318
- generateDiscountCardHTML(item) {
319
- return `
320
- <html>
321
- <head>
322
- <meta charset="utf-8">
323
- <style>
324
- @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&family=Montserrat:wght@600;700;800&display=swap');
325
- body {
326
- margin: 0;
327
- padding: 0;
328
- font-family: 'Noto Sans SC', sans-serif;
329
- background: linear-gradient(135deg, #ff6b6b 0%, #ffa502 100%);
330
- display: flex;
331
- justify-content: center;
332
- align-items: center;
333
- min-height: 100vh;
334
- }
335
- .container {
336
- width: 640px;
337
- background: white;
338
- border-radius: 20px;
339
- overflow: hidden;
340
- box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
341
- position: relative;
342
- }
343
- .header {
344
- background: linear-gradient(90deg, #ff6b6b, #ffa502);
345
- padding: 25px;
346
- text-align: center;
347
- position: relative;
348
- color: white;
349
- }
350
- .discount-badge {
351
- position: absolute;
352
- top: 20px;
353
- right: 20px;
354
- background: #e74c3c;
355
- color: white;
356
- padding: 10px 15px;
357
- border-radius: 30px;
358
- font-weight: 700;
359
- font-size: 18px;
360
- box-shadow: 0 4px 8px rgba(0,0,0,0.2);
361
- }
362
- .label {
363
- background: rgba(255, 255, 255, 0.2);
364
- backdrop-filter: blur(10px);
365
- padding: 8px 20px;
366
- border-radius: 30px;
367
- font-size: 14px;
368
- font-weight: 500;
369
- display: inline-block;
370
- margin-bottom: 15px;
371
- border: 1px solid rgba(255, 255, 255, 0.3);
372
- }
373
- .booth-logo {
374
- font-family: 'Montserrat', sans-serif;
375
- font-weight: 800;
376
- font-size: 36px;
377
- letter-spacing: 2px;
378
- text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
379
- }
380
- .content {
381
- padding: 30px;
382
- }
383
- .main-image {
384
- width: 100%;
385
- height: 320px;
386
- background: #f0f0f0 url('${item.image_url}') center/cover;
387
- border-radius: 15px;
388
- margin-bottom: 25px;
389
- box-shadow: 0 10px 20px rgba(0,0,0,0.1);
390
- border: 1px solid rgba(0,0,0,0.05);
391
- }
392
- .product-title {
393
- font-size: 26px;
394
- margin: 0 0 20px 0;
395
- color: #2c3e50;
396
- font-weight: 700;
397
- line-height: 1.4;
398
- }
399
- .price-section {
400
- display: flex;
401
- align-items: center;
402
- justify-content: center;
403
- gap: 20px;
404
- margin-bottom: 30px;
405
- padding: 20px;
406
- background: #fff9f9;
407
- border-radius: 12px;
408
- border: 2px dashed #e74c3c;
409
- }
410
- .original-price {
411
- font-size: 24px;
412
- color: #7f8c8d;
413
- text-decoration: line-through;
414
- }
415
- .current-price {
416
- font-size: 32px;
417
- font-weight: 700;
418
- color: #e74c3c;
419
- }
420
- .discount-info {
421
- text-align: center;
422
- font-size: 18px;
423
- color: #e74c3c;
424
- font-weight: 600;
425
- }
426
- .author-section {
427
- display: flex;
428
- align-items: center;
429
- gap: 15px;
430
- margin-bottom: 25px;
431
- padding: 15px;
432
- background: #f8f9fa;
433
- border-radius: 12px;
434
- border-left: 4px solid #3498db;
435
- }
436
- .author-avatar {
437
- width: 60px;
438
- height: 60px;
439
- border-radius: 50%;
440
- border: 3px solid #fff;
441
- box-shadow: 0 4px 8px rgba(0,0,0,0.1);
442
- object-fit: cover;
443
- }
444
- .author-info {
445
- flex: 1;
446
- }
447
- .author-name {
448
- font-size: 18px;
449
- font-weight: 600;
450
- color: #2c3e50;
451
- margin-bottom: 4px;
452
- }
453
- .tags {
454
- display: flex;
455
- flex-wrap: wrap;
456
- gap: 8px;
457
- margin-bottom: 25px;
458
- }
459
- .tag {
460
- background: #e1f0fa;
461
- color: #3498db;
462
- padding: 6px 12px;
463
- border-radius: 20px;
464
- font-size: 13px;
465
- font-weight: 500;
466
- }
467
- .footer {
468
- background: #2c3e50;
469
- padding: 20px;
470
- text-align: center;
471
- color: #ecf0f1;
472
- font-size: 14px;
473
- }
474
- .link {
475
- color: #3498db;
476
- text-decoration: none;
477
- font-weight: 500;
478
- }
479
- </style>
480
- </head>
481
- <body>
482
- <div class="container">
483
- <div class="header">
484
- <div class="discount-badge">-${item.discount_rate}% OFF</div>
485
- <div class="label">DISCOUNT ITEM</div>
486
- <div class="booth-logo">BOOTH</div>
487
- </div>
318
+ generateDiscountCardHTML(item) {
319
+ const savedAmount = item.original_price - item.price;
320
+
321
+ return `
322
+ <html>
323
+ <head>
324
+ <meta charset="utf-8">
325
+ <style>
326
+ @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&family=Montserrat:wght@600;700;800&display=swap');
327
+ body {
328
+ margin: 0;
329
+ padding: 0;
330
+ font-family: 'Noto Sans SC', sans-serif;
331
+ background: linear-gradient(135deg, #ff6b6b 0%, #ffa502 100%);
332
+ display: flex;
333
+ justify-content: center;
334
+ align-items: center;
335
+ min-height: 100vh;
336
+ }
337
+ .container {
338
+ width: 500px;
339
+ background: white;
340
+ border-radius: 20px;
341
+ overflow: hidden;
342
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
343
+ }
344
+ .header {
345
+ background: linear-gradient(90deg, #ff6b6b, #ffa502);
346
+ padding: 20px;
347
+ text-align: center;
348
+ color: white;
349
+ position: relative;
350
+ }
351
+ .discount-badge {
352
+ position: absolute;
353
+ top: 15px;
354
+ right: 15px;
355
+ background: #e74c3c;
356
+ color: white;
357
+ padding: 8px 12px;
358
+ border-radius: 20px;
359
+ font-weight: 700;
360
+ font-size: 14px;
361
+ box-shadow: 0 4px 8px rgba(0,0,0,0.2);
362
+ }
363
+ .new-badge {
364
+ position: absolute;
365
+ top: 15px;
366
+ left: 15px;
367
+ background: #27ae60;
368
+ color: white;
369
+ padding: 8px 12px;
370
+ border-radius: 20px;
371
+ font-weight: 700;
372
+ font-size: 14px;
373
+ box-shadow: 0 4px 8px rgba(0,0,0,0.2);
374
+ }
375
+ .booth-logo {
376
+ font-family: 'Montserrat', sans-serif;
377
+ font-weight: 800;
378
+ font-size: 28px;
379
+ letter-spacing: 2px;
380
+ }
381
+ .content {
382
+ padding: 25px;
383
+ }
384
+ .main-image {
385
+ width: 100%;
386
+ height: 250px;
387
+ background: #f0f0f0 url('${item.image_url}') center/cover;
388
+ border-radius: 12px;
389
+ margin-bottom: 20px;
390
+ box-shadow: 0 8px 16px rgba(0,0,0,0.1);
391
+ }
392
+ .product-title {
393
+ font-size: 20px;
394
+ margin: 0 0 15px 0;
395
+ color: #2c3e50;
396
+ font-weight: 700;
397
+ line-height: 1.4;
398
+ }
399
+ .author-section {
400
+ display: flex;
401
+ align-items: center;
402
+ gap: 12px;
403
+ margin-bottom: 20px;
404
+ padding: 12px;
405
+ background: #f8f9fa;
406
+ border-radius: 10px;
407
+ }
408
+ .author-avatar {
409
+ width: 50px;
410
+ height: 50px;
411
+ border-radius: 50%;
412
+ border: 2px solid #fff;
413
+ box-shadow: 0 4px 8px rgba(0,0,0,0.1);
414
+ object-fit: cover;
415
+ }
416
+ .author-info {
417
+ flex: 1;
418
+ }
419
+ .author-name {
420
+ font-size: 16px;
421
+ font-weight: 600;
422
+ color: #2c3e50;
423
+ }
424
+ .price-section {
425
+ display: flex;
426
+ align-items: center;
427
+ justify-content: space-between;
428
+ margin-bottom: 15px;
429
+ padding: 15px;
430
+ background: #fff9f9;
431
+ border-radius: 10px;
432
+ border: 2px dashed #e74c3c;
433
+ }
434
+ .original-price {
435
+ font-size: 18px;
436
+ color: #7f8c8d;
437
+ text-decoration: line-through;
438
+ }
439
+ .current-price {
440
+ font-size: 24px;
441
+ font-weight: 700;
442
+ color: #e74c3c;
443
+ }
444
+ .savings {
445
+ text-align: center;
446
+ font-size: 16px;
447
+ color: #27ae60;
448
+ font-weight: 600;
449
+ margin-bottom: 15px;
450
+ }
451
+ .tags {
452
+ display: flex;
453
+ flex-wrap: wrap;
454
+ gap: 6px;
455
+ margin-bottom: 20px;
456
+ }
457
+ .tag {
458
+ background: #e1f0fa;
459
+ color: #3498db;
460
+ padding: 4px 10px;
461
+ border-radius: 15px;
462
+ font-size: 12px;
463
+ font-weight: 500;
464
+ }
465
+ .footer {
466
+ background: #2c3e50;
467
+ padding: 15px;
468
+ text-align: center;
469
+ color: #ecf0f1;
470
+ font-size: 12px;
471
+ }
472
+ .link {
473
+ color: #3498db;
474
+ text-decoration: none;
475
+ font-weight: 500;
476
+ }
477
+ </style>
478
+ </head>
479
+ <body>
480
+ <div class="container">
481
+ <div class="header">
482
+ <div class="new-badge">NEW</div>
483
+ <div class="discount-badge">-${item.discount_rate}%</div>
484
+ <div class="booth-logo">BOOTH</div>
485
+ <div style="margin-top: 8px; font-size: 14px;">最新折扣商品</div>
486
+ </div>
487
+
488
+ <div class="content">
489
+ <div class="main-image"></div>
490
+ <h1 class="product-title">${item.title}</h1>
488
491
 
489
- <div class="content">
490
- <div class="main-image"></div>
491
- <h1 class="product-title">${item.title}</h1>
492
-
493
- <div class="author-section">
494
- <img src="${item.author_thumbnail_url || 'https://s2.booth.pm/static-images/user/guest-32.png'}"
495
- class="author-avatar"
496
- alt="作者头像" onerror="this.src='https://s2.booth.pm/static-images/user/guest-32.png'">
497
- <div class="author-info">
498
- <div class="author-name">${item.author}</div>
499
- <div class="author-label">BOOTHクリエイター</div>
500
- </div>
501
- </div>
502
-
503
- <div class="price-section">
504
- <div class="original-price">¥${item.original_price.toLocaleString()}</div>
505
- <div class="current-price">¥${item.price.toLocaleString()}</div>
506
- </div>
507
-
508
- <div class="discount-info">
509
- 节省 ¥${(item.original_price - item.price).toLocaleString()} (${item.discount_rate}% 折扣)
510
- </div>
511
-
512
- <div class="tags">
513
- ${(item.tags || []).slice(0, 5).map(tag => `<div class="tag">${tag.name}</div>`).join('')}
492
+ <div class="author-section">
493
+ <img src="${item.author_thumbnail_url || 'https://s2.booth.pm/static-images/user/guest-32.png'}"
494
+ class="author-avatar"
495
+ alt="作者头像" onerror="this.src='https://s2.booth.pm/static-images/user/guest-32.png'">
496
+ <div class="author-info">
497
+ <div class="author-name">${item.author}</div>
514
498
  </div>
515
499
  </div>
516
500
 
517
- <div class="footer">
518
- 由VRCBBS提供 | 商品链接:
519
- <a href="${item.url}" class="link">${item.url}</a>
501
+ <div class="price-section">
502
+ <div class="original-price">¥${item.original_price.toLocaleString()}</div>
503
+ <div class="current-price">¥${item.price.toLocaleString()}</div>
504
+ </div>
505
+
506
+ <div class="savings">
507
+ 节省 ¥${savedAmount.toLocaleString()} (${item.discount_rate}% 折扣)
508
+ </div>
509
+
510
+ ${item.tags && item.tags.length > 0 ? `
511
+ <div class="tags">
512
+ ${item.tags.slice(0, 5).map(tag => `<div class="tag">${tag.name}</div>`).join('')}
520
513
  </div>
514
+ ` : ''}
521
515
  </div>
522
- </body>
523
- </html>`;
524
- }
516
+
517
+ <div class="footer">
518
+ 由VRCBBS提供 | BOOTH链接:
519
+ <a href="https://booth.pm/zh-cn/items/${item.id}"
520
+ class="link">
521
+ https://booth.pm/zh-cn/items/${item.id}
522
+ </a>
523
+ </div>
524
+ </div>
525
+ </body>
526
+ </html>`;
527
+ }
525
528
 
526
529
  async captureCardHTML(html, config) {
527
530
  const page = await this.ctx.puppeteer.page();
528
531
  try {
529
- await page.setRequestInterception(true);
530
- page.on('request', (request) => request.continue());
531
532
 
532
533
  await page.setContent(html, {
533
- waitUntil: 'domcontentloaded',
534
+ waitUntil: 'networkidle0',
534
535
  timeout: config.loadTimeout
535
536
  });
536
537
 
537
- await new Promise(resolve => setTimeout(resolve, 1200));
538
+ await new Promise(resolve => setTimeout(resolve, 3000));
538
539
 
539
540
  await page.setViewport({ width: 640, height: 1200 });
540
541
  const container = await page.$('.container') || await page.$('body');
@@ -115,8 +115,6 @@ class CommandHandler {
115
115
  );
116
116
  return subs.length > 0 ? `📌 群组订阅的作者有:\n${subs.join("\n")}` : "📭 群组还没有订阅任何作者";
117
117
  });
118
-
119
- // 移除了"折扣群组订阅"和"折扣群组退订"两个命令
120
118
  }
121
119
 
122
120
  registerMiddleware() {
@@ -1,251 +1,497 @@
1
1
  const fs = require('fs').promises;
2
2
  const path = require('path');
3
- const import_koishi = require("koishi");
3
+ const { h } = require('koishi');
4
4
 
5
5
  class DiscountTracker {
6
6
  constructor() {
7
7
  this.ctx = null;
8
8
  this.config = null;
9
- this.DISCOUNT_ITEMS_FILE = path.join(__dirname, "discount_items.json");
10
- this.DISCOUNT_GROUPS_FILE = path.join(__dirname, "discount_groups.json");
11
9
  this.discountCache = new Map();
12
- this.CACHE_DURATION = 24 * 60 * 60 * 1000;
10
+ this.recentlyPushed = new Map();
11
+ this.lastDiscountItems = [];
12
+ this._intervalHandles = [];
13
+ this._running = false;
13
14
  }
14
15
 
15
16
  init(ctx, config) {
16
17
  this.ctx = ctx;
17
- this.config = config;
18
+ this.config = Object.assign({
19
+ enableDiscountTracking: true,
20
+ discountCheckInterval: 60,
21
+ maxDiscountPushCount: 3,
22
+ targetGroups: [],
23
+ searchTags: ['VRChat', '3Dモデル', 'Avatar'],
24
+ discountKeywords: ['セール', 'sale', '割引', '値引き', '特価', 'キャンペーン', '期間限定', 'クーポン', 'OFF', 'off', 'discount', 'オフ', '%OFF', '%off', '%オフ'],
25
+ minDiscountRate: 5,
26
+ maxItemsPerRun: 20,
27
+ detailPageConcurrency: 1
28
+ }, config || {});
18
29
 
19
- if (config.enableDiscountTracking) {
30
+ if (this.config.enableDiscountTracking) {
20
31
  this.startDiscountTracking();
32
+ this.ctx.logger('booth-discount').info('折扣追踪启动 - 增强检测模式');
21
33
  }
22
34
  }
23
35
 
24
36
  cleanupExpiredCache() {
25
37
  const now = Date.now();
26
- for (const [id, timestamp] of this.discountCache.entries()) {
27
- if (now - timestamp > this.CACHE_DURATION) {
28
- this.discountCache.delete(id);
29
- }
38
+ for (const [id, ts] of Array.from(this.discountCache.entries())) {
39
+ if (now - ts > 24 * 60 * 60 * 1000) this.discountCache.delete(id);
40
+ }
41
+ for (const [id, ts] of Array.from(this.recentlyPushed.entries())) {
42
+ if (now - ts > 6 * 60 * 60 * 1000) this.recentlyPushed.delete(id);
30
43
  }
31
44
  }
32
45
 
33
- isCached(itemId) {
34
- const timestamp = this.discountCache.get(itemId);
35
- if (!timestamp) return false;
46
+ async fetchItemIdsFromListPage() {
47
+ const logger = this.ctx.logger('booth-discount');
48
+ const all = [];
36
49
 
37
- if (Date.now() - timestamp > this.CACHE_DURATION) {
38
- this.discountCache.delete(itemId);
39
- return false;
40
- }
41
- return true;
42
- }
50
+ for (const tag of this.config.searchTags) {
51
+ let page;
52
+ try {
53
+ page = await this.ctx.puppeteer.page();
54
+
55
+ const url = `https://booth.pm/zh-cn/items?tags%5B%5D=${encodeURIComponent(tag)}&sort=new`;
56
+ logger.info(`获取商品ID: ${url}`);
57
+
58
+ await page.goto(url, {
59
+ waitUntil: 'networkidle0',
60
+ timeout: 30000
61
+ });
62
+
63
+ await page.waitForSelector('.js-mount-point-shop-item-card, .item-card', {
64
+ timeout: 15000
65
+ });
43
66
 
44
- addToCache(itemId) {
45
- this.discountCache.set(itemId, Date.now());
46
- }
67
+ const ids = await page.evaluate(() => {
68
+ const out = [];
69
+ const els = Array.from(document.querySelectorAll('.js-mount-point-shop-item-card, .item-card'));
70
+
71
+ els.forEach(el => {
72
+ try {
73
+ const attr = el.getAttribute('data-item');
74
+ if (attr) {
75
+ try {
76
+ const j = JSON.parse(attr);
77
+ if (j && j.id) {
78
+ out.push(String(j.id));
79
+ return;
80
+ }
81
+ } catch {}
82
+ }
83
+
84
+ const a = el.querySelector('a[href*="/items/"]');
85
+ if (a) {
86
+ const href = a.getAttribute('href');
87
+ const m = href.match(/\/items\/(\d+)/);
88
+ if (m) out.push(m[1]);
89
+ }
90
+ } catch (e) {}
91
+ });
92
+ return out;
93
+ });
94
+
95
+ ids.forEach(id => all.push(id));
96
+ logger.info(`标签 ${tag} 找到 ${ids.length} 个商品ID`);
47
97
 
48
- async loadJSON(file, def = {}) {
49
- try {
50
- const raw = await fs.readFile(file, "utf8");
51
- return JSON.parse(raw);
52
- } catch (e) {
53
- return def;
98
+ } catch (e) {
99
+ logger.error(`获取商品ID失败 ${tag}: ${e.message}`);
100
+ } finally {
101
+ if (page) {
102
+ try {
103
+ await page.close();
104
+ } catch (e) {}
105
+ }
106
+ }
54
107
  }
108
+
109
+ const uniqueIds = Array.from(new Set(all)).slice(0, this.config.maxItemsPerRun || 15);
110
+ logger.info(`总计找到 ${uniqueIds.length} 个唯一商品ID`);
111
+ return uniqueIds;
55
112
  }
56
113
 
57
- async saveJSON(file, data) {
58
- await fs.mkdir(path.dirname(file), { recursive: true }).catch(() => {});
59
- await fs.writeFile(file, JSON.stringify(data, null, 2), "utf8");
60
- }
61
-
62
- async searchDiscountItems() {
63
- const logger = this.ctx.logger("booth-discount");
64
- const page = await this.ctx.puppeteer.page();
114
+ async getItemDetails(itemId) {
115
+ const logger = this.ctx.logger('booth-discount');
116
+ let page;
65
117
 
66
118
  try {
67
- const tagsParam = this.config.targetTags.map(tag => `tags[]=${encodeURIComponent(tag)}`).join('&');
68
- const searchUrl = `https://booth.pm/zh-cn/search?sort=new&in_stock=true&${tagsParam}`;
119
+ page = await this.ctx.puppeteer.page();
69
120
 
70
- await page.setRequestInterception(true);
71
- page.on('request', (request) => {
72
- const resourceType = request.resourceType();
73
- if (['image', 'stylesheet', 'font'].includes(resourceType)) {
74
- request.abort();
75
- } else {
76
- request.continue();
77
- }
78
- });
79
-
80
- await page.goto(searchUrl, {
121
+ const url = `https://booth.pm/zh-cn/items/${itemId}`;
122
+ logger.info(`检查商品详情: ${url}`);
123
+
124
+ await page.goto(url, {
81
125
  waitUntil: 'networkidle0',
82
- timeout: this.config.loadTimeout
126
+ timeout: 30000
83
127
  });
128
+
129
+ await page.waitForSelector('body', { timeout: 10000 });
130
+
131
+ const itemData = await page.evaluate((discountKeywords) => {
132
+ let currentPrice = 0;
133
+ let originalPrice = 0;
134
+ let discountRate = 0;
135
+ let title = '';
136
+ let author = '';
137
+ let imageUrl = '';
138
+ let hasDiscountKeyword = false;
139
+ let discountKeywordFound = '';
140
+ let priceTextContent = '';
141
+ let originalPriceTextContent = '';
142
+
143
+ const pageText = document.body.innerText;
144
+ const pageTextLower = pageText.toLowerCase();
145
+
146
+ for (const keyword of discountKeywords) {
147
+ if (pageTextLower.includes(keyword.toLowerCase())) {
148
+ hasDiscountKeyword = true;
149
+ discountKeywordFound = keyword;
150
+ break;
151
+ }
152
+ }
153
+
154
+ const priceSelectors = [
155
+ '.price', '.product-price', '.item-price',
156
+ '[class*="price"]', '.u-text-weight-bold',
157
+ '.u-text-right', '.u-text-left'
158
+ ];
159
+
160
+ const originalPriceSelectors = [
161
+ '.original-price', '.list-price', 'del', 's',
162
+ '[class*="original"]', '[class*="list"]'
163
+ ];
84
164
 
85
- await page.waitForSelector('.item-list', { timeout: 10000 }).catch(() => {});
86
-
87
- const items = await page.evaluate(() => {
88
- const itemElements = Array.from(document.querySelectorAll('.js-mount-point-shop-item-card'));
89
- return itemElements.map(el => {
90
- try {
91
- const dataItem = JSON.parse(el.getAttribute('data-item'));
92
-
93
- const priceEl = el.querySelector('.price');
94
- const originalPriceEl = el.querySelector('.original-price');
95
-
96
- let originalPrice = dataItem.price;
97
- let discountPrice = dataItem.price;
98
- let discountRate = 0;
99
-
100
- if (originalPriceEl) {
101
- const originalText = originalPriceEl.textContent.trim();
102
- const originalMatch = originalText.match(/[\d,]+/);
103
- if (originalMatch) {
104
- originalPrice = parseInt(originalMatch[0].replace(/,/g, '')) || dataItem.price;
165
+ for (const selector of priceSelectors) {
166
+ const elements = document.querySelectorAll(selector);
167
+ for (const el of elements) {
168
+ const text = el.textContent.trim();
169
+ const priceMatch = text.match(/(\d{1,3}(?:,\d{3})*)/);
170
+ if (priceMatch && !text.includes('¥') && text.length < 50) {
171
+ const price = parseInt(priceMatch[1].replace(/,/g, ''), 10);
172
+ if (price > 100 && price < 1000000) {
173
+ currentPrice = price;
174
+ priceTextContent = text;
175
+ break;
105
176
  }
106
177
  }
107
-
108
- if (priceEl && originalPriceEl) {
109
- const priceText = priceEl.textContent.trim();
110
- const priceMatch = priceText.match(/[\d,]+/);
111
- if (priceMatch) {
112
- discountPrice = parseInt(priceMatch[0].replace(/,/g, '')) || dataItem.price;
178
+ }
179
+ if (currentPrice > 0) break;
180
+ }
181
+
182
+ for (const selector of originalPriceSelectors) {
183
+ const elements = document.querySelectorAll(selector);
184
+ for (const el of elements) {
185
+ const text = el.textContent.trim();
186
+ const priceMatch = text.match(/(\d{1,3}(?:,\d{3})*)/);
187
+ if (priceMatch) {
188
+ const price = parseInt(priceMatch[1].replace(/,/g, ''), 10);
189
+ if (price > currentPrice && price < 1000000) {
190
+ originalPrice = price;
191
+ originalPriceTextContent = text;
192
+ break;
113
193
  }
114
194
  }
115
-
116
- if (originalPrice > discountPrice) {
117
- discountRate = Math.round((1 - discountPrice / originalPrice) * 100);
195
+ }
196
+ if (originalPrice > 0) break;
197
+ }
198
+
199
+ if (originalPrice === 0 && pageText.match(/\d+>\d+/)) {
200
+ const rangeMatch = pageText.match(/(\d+)>\s*(\d+)/);
201
+ if (rangeMatch) {
202
+ const larger = Math.max(parseInt(rangeMatch[1]), parseInt(rangeMatch[2]));
203
+ const smaller = Math.min(parseInt(rangeMatch[1]), parseInt(rangeMatch[2]));
204
+ if (larger > smaller) {
205
+ originalPrice = larger;
206
+ currentPrice = smaller;
118
207
  }
119
-
120
- const imageUrl = dataItem.thumbnail_image_urls?.[0] ||
121
- dataItem.images?.[0]?.original ||
122
- el.querySelector('.swap-image img')?.src ||
123
- 'https://s2.booth.pm/static-images/item/empty-preview.png';
124
-
125
- return {
126
- id: dataItem.id,
127
- title: dataItem.name,
128
- price: discountPrice,
129
- original_price: originalPrice,
130
- discount_rate: discountRate,
131
- image_url: imageUrl,
132
- author: dataItem.shop?.name,
133
- author_thumbnail_url: dataItem.shop?.thumbnail_url,
134
- url: `https://booth.pm/zh-cn/items/${dataItem.id}`,
135
- tags: dataItem.tags || []
136
- };
137
- } catch (e) {
138
- return null;
139
208
  }
140
- }).filter(item => item !== null && item.discount_rate > 0);
141
- });
209
+ }
210
+
211
+ const dataItemEl = document.querySelector('.js-mount-point-shop-item-card');
212
+ if (dataItemEl) {
213
+ const dataItemAttr = dataItemEl.getAttribute('data-item');
214
+ if (dataItemAttr) {
215
+ try {
216
+ const dataItem = JSON.parse(dataItemAttr);
217
+ if (dataItem.price && !currentPrice) {
218
+ currentPrice = dataItem.price;
219
+ }
220
+ if (dataItem.name && !title) {
221
+ title = dataItem.name;
222
+ }
223
+ if (dataItem.shop && dataItem.shop.name && !author) {
224
+ author = dataItem.shop.name;
225
+ }
226
+ if (dataItem.images && dataItem.images[0] && !imageUrl) {
227
+ imageUrl = dataItem.images[0].original;
228
+ }
229
+ } catch (e) {}
230
+ }
231
+ }
232
+
233
+ if (!title) {
234
+ const titleEl = document.querySelector('.item-name, .product-name, h1');
235
+ if (titleEl) title = titleEl.textContent.trim();
236
+ }
237
+
238
+ if (!author) {
239
+ const authorEl = document.querySelector('.shop-name, .author-name, [class*="shop"]');
240
+ if (authorEl) author = authorEl.textContent.trim();
241
+ }
242
+
243
+ if (!imageUrl) {
244
+ const imageEl = document.querySelector('.item-image img, .product-image img, [class*="image"] img');
245
+ if (imageEl) imageUrl = imageEl.src;
246
+ }
247
+
248
+ if (originalPrice > currentPrice && originalPrice > 0) {
249
+ discountRate = Math.round((1 - currentPrice / originalPrice) * 100);
250
+ } else {
251
+ originalPrice = currentPrice;
252
+ }
253
+
254
+ if (hasDiscountKeyword && discountRate === 0) {
255
+ discountRate = 10;
256
+ }
257
+
258
+ return {
259
+ price: currentPrice,
260
+ original_price: originalPrice,
261
+ discount_rate: discountRate,
262
+ title,
263
+ author,
264
+ image_url: imageUrl,
265
+ has_discount_keyword: hasDiscountKeyword,
266
+ discount_keyword: discountKeywordFound,
267
+ price_text: priceTextContent,
268
+ original_price_text: originalPriceTextContent
269
+ };
270
+ }, this.config.discountKeywords);
271
+
272
+ const result = {
273
+ id: String(itemId),
274
+ title: itemData.title || '',
275
+ price: itemData.price || 0,
276
+ original_price: itemData.original_price || 0,
277
+ discount_rate: itemData.discount_rate || 0,
278
+ image_url: itemData.image_url || 'https://s2.booth.pm/static-images/item/empty-preview.png',
279
+ author: itemData.author || '',
280
+ url: `https://booth.pm/zh-cn/items/${itemId}`,
281
+ tags: [],
282
+ has_discount_keyword: itemData.has_discount_keyword || false,
283
+ discount_keyword: itemData.discount_keyword || '',
284
+ price_text: itemData.price_text || '',
285
+ original_price_text: itemData.original_price_text || ''
286
+ };
287
+
288
+ if (result.has_discount_keyword || result.discount_rate > 0) {
289
+ logger.info(`商品 ${itemId}:`);
290
+ logger.info(`- 标题: ${result.title}`);
291
+ logger.info(`- 价格: ${result.price}¥ (${result.price_text})`);
292
+ logger.info(`- 原价: ${result.original_price}¥ (${result.original_price_text})`);
293
+ logger.info(`- 折扣率: ${result.discount_rate}%`);
294
+ if (result.has_discount_keyword) {
295
+ logger.info(`- 折扣关键词: ${result.discount_keyword}`);
296
+ }
297
+ }
298
+
299
+ return result;
300
+
301
+ } catch (e) {
302
+ logger.debug(`获取商品详情失败 ${itemId}: ${e.message}`);
303
+ return null;
304
+ } finally {
305
+ if (page) {
306
+ try {
307
+ await page.close();
308
+ } catch (e) {}
309
+ }
310
+ }
311
+ }
312
+
313
+ async searchDiscountItems() {
314
+ const logger = this.ctx.logger('booth-discount');
315
+
316
+ try {
317
+ const ids = await this.fetchItemIdsFromListPage();
318
+ if (!ids || ids.length === 0) {
319
+ logger.info('未找到商品ID');
320
+ return [];
321
+ }
142
322
 
143
- await page.close();
144
- return items;
323
+ logger.info(`开始获取 ${ids.length} 个商品详情`);
324
+ const results = [];
325
+
326
+ for (let i = 0; i < ids.length; i++) {
327
+ const id = ids[i];
328
+ try {
329
+ const item = await this.getItemDetails(id);
330
+ if (item) {
331
+ results.push(item);
332
+ }
333
+ await new Promise(r => setTimeout(r, 2000));
334
+ } catch (e) {
335
+ logger.debug(`处理商品失败 ${id}: ${e.message}`);
336
+ }
337
+ }
338
+
339
+ const discountItems = results.filter(item =>
340
+ item.discount_rate >= this.config.minDiscountRate || item.has_discount_keyword
341
+ );
342
+
343
+ logger.info(`详情获取完成: ${results.length} 商品`);
344
+ logger.info(`- 关键词折扣: ${results.filter(item => item.has_discount_keyword).length} 个`);
345
+ logger.info(`- 价格折扣: ${results.filter(item => item.discount_rate >= this.config.minDiscountRate).length} 个`);
346
+ logger.info(`- 总计折扣: ${discountItems.length} 个`);
347
+
348
+ return discountItems;
349
+
145
350
  } catch (error) {
146
- logger.error('搜索折扣商品失败:', error);
147
- await page.close();
351
+ logger.error(`搜索折扣商品失败: ${error.message}`);
148
352
  return [];
149
353
  }
150
354
  }
151
355
 
152
356
  async notifyDiscountGroups(discountItems) {
153
- const logger = this.ctx.logger("booth-discount");
357
+ const logger = this.ctx.logger('booth-discount');
154
358
  const cardGenerator = require('./card-generator');
155
359
 
156
- const itemsToPush = discountItems.slice(0, this.config.maxDiscountPushCount || 5);
157
-
158
- for (const groupKey of this.config.targetGroups || []) {
159
- const parts = groupKey.split(':');
160
- if (parts.length < 3) continue;
161
- const platform = parts[1];
162
- const channelId = parts.slice(2).join(':');
360
+ if (!this.config.targetGroups || this.config.targetGroups.length === 0) {
361
+ logger.info('未配置目标群组');
362
+ return;
363
+ }
364
+
365
+ const newDiscountItems = discountItems
366
+ .filter(item => !this.discountCache.has(item.id) && !this.recentlyPushed.has(item.id))
367
+ .slice(0, this.config.maxDiscountPushCount || 3);
368
+
369
+ if (newDiscountItems.length === 0) {
370
+ logger.info('无新折扣商品');
371
+ return;
372
+ }
373
+
374
+ logger.info(`推送 ${newDiscountItems.length} 个新商品`);
375
+ await this.sendItemsToGroups(newDiscountItems);
376
+ this.lastDiscountItems = discountItems.slice();
377
+ }
378
+
379
+ async sendItemsToGroups(items) {
380
+ const logger = this.ctx.logger('booth-discount');
381
+ const cardGenerator = require('./card-generator');
382
+
383
+ for (const item of items) {
384
+ try {
385
+ const html = cardGenerator.generateDiscountCardHTML(item);
386
+ const buffer = await cardGenerator.captureCardHTML(html, this.config);
387
+
388
+ if (!buffer) {
389
+ logger.warn(`生成卡片失败: ${item.title}`);
390
+ continue;
391
+ }
392
+
393
+ const savedAmount = (item.original_price || 0) - (item.price || 0);
394
+ let message = `🎉 发现折扣商品!\n\n${item.title}\n作者:${item.author || ''}\n`;
395
+
396
+ if (item.has_discount_keyword) {
397
+ message += `🔍 折扣关键词: ${item.discount_keyword}\n`;
398
+ }
163
399
 
164
- for (const item of itemsToPush) {
400
+ message += `💰 价格:¥${(item.price||0).toLocaleString()}(原价:¥${(item.original_price||0).toLocaleString()})\n`;
401
+ message += `🎊 折扣:${item.discount_rate}% OFF\n`;
402
+ message += `💵 节省:¥${savedAmount.toLocaleString()}\n`;
403
+ message += `🔗 链接:${item.url}`;
404
+
405
+ for (const channelId of this.config.targetGroups) {
165
406
  try {
166
- const html = cardGenerator.generateDiscountCardHTML(item);
167
- const buffer = await cardGenerator.captureCardHTML(html, this.config);
168
- if (!buffer) continue;
169
-
170
- const message = `🎉 发现折扣商品!\n` +
171
- `商品链接:${item.url}\n` +
172
- `原价:¥${item.original_price.toLocaleString()}\n` +
173
- `现价:¥${item.price.toLocaleString()}\n` +
174
- `折扣:${item.discount_rate}% OFF\n` +
175
- `节省:¥${(item.original_price - item.price).toLocaleString()}`;
176
-
177
- await this.ctx.bots[0].sendMessage(channelId, [message, import_koishi.h.image(buffer, "image/png")]);
178
-
179
- await new Promise(resolve => setTimeout(resolve, 1000));
407
+ if (channelId.includes(':')) {
408
+ const [platform, id] = channelId.split(':', 2);
409
+ for (const bot of this.ctx.bots) {
410
+ if (bot.platform === platform) {
411
+ await bot.sendMessage(id, [message, h.image(buffer, 'image/png')]);
412
+ break;
413
+ }
414
+ }
415
+ } else {
416
+ for (const bot of this.ctx.bots) {
417
+ try {
418
+ await bot.sendMessage(channelId, [message, h.image(buffer, 'image/png')]);
419
+ break;
420
+ } catch (e) {
421
+ continue;
422
+ }
423
+ }
424
+ }
425
+ await new Promise(r => setTimeout(r, 2000));
180
426
  } catch (e) {
181
- logger.warn(`向群组 ${channelId} 推送折扣信息失败:`, e);
427
+ logger.warn(`推送失败 ${channelId}: ${e.message}`);
182
428
  }
183
429
  }
430
+
431
+ this.recentlyPushed.set(item.id, Date.now());
432
+ this.discountCache.set(item.id, Date.now());
433
+ await new Promise(r => setTimeout(r, 3000));
434
+
435
+ } catch (err) {
436
+ logger.error(`推送错误: ${err.message}`);
184
437
  }
185
438
  }
439
+ }
186
440
 
187
441
  startDiscountTracking() {
188
- const logger = this.ctx.logger("booth-discount");
189
- logger.info('启动折扣商品追踪器');
442
+ const logger = this.ctx.logger('booth-discount');
443
+ const checkInterval = (this.config.discountCheckInterval || 60) * 60 * 1000;
190
444
 
191
- this.ctx.setInterval(async () => {
192
- try {
193
- this.cleanupExpiredCache();
194
-
195
- logger.info('开始检查折扣商品...');
196
- const discountItems = await this.searchDiscountItems();
197
- const knownItems = await this.loadJSON(this.DISCOUNT_ITEMS_FILE);
198
- const newDiscountItems = [];
199
-
200
- for (const item of discountItems) {
201
- if (!this.isCached(item.id)) {
202
- if (!knownItems[item.id] || knownItems[item.id].discount_rate !== item.discount_rate) {
203
- newDiscountItems.push(item);
204
- this.addToCache(item.id);
205
- knownItems[item.id] = {
206
- ...item,
207
- first_seen: knownItems[item.id]?.first_seen || Date.now(),
208
- last_updated: Date.now()
209
- };
210
- }
211
- }
212
- }
213
-
214
- if (newDiscountItems.length > 0) {
215
- logger.info(`发现 ${newDiscountItems.length} 个新的折扣商品`);
216
- await this.saveJSON(this.DISCOUNT_ITEMS_FILE, knownItems);
217
- await this.notifyDiscountGroups(newDiscountItems);
218
- } else {
219
- logger.info('未发现新的折扣商品');
220
- }
221
- } catch (error) {
222
- logger.error('折扣商品检查失败:', error);
223
- }
224
- }, (this.config.discountCheckInterval || 60) * 60 * 1000);
445
+ logger.info(`折扣追踪启动,间隔:${this.config.discountCheckInterval}分钟`);
446
+ logger.info(`搜索标签: ${this.config.searchTags.join(', ')}`);
447
+ logger.info(`折扣关键词: ${this.config.discountKeywords.join(', ')}`);
448
+ logger.info(`最低折扣率: ${this.config.minDiscountRate}%`);
449
+
450
+ this.performDiscountCheck();
451
+
452
+ const checkHandle = this.ctx.setInterval(() => {
453
+ this.performDiscountCheck();
454
+ }, checkInterval);
455
+
456
+ const cleanupHandle = this.ctx.setInterval(() => {
457
+ this.cleanupExpiredCache();
458
+ }, 60 * 60 * 1000);
459
+
460
+ this._intervalHandles.push(checkHandle, cleanupHandle);
225
461
  }
226
462
 
227
- async addDiscountGroupSubscription(platform, channelId) {
228
- const groupKey = `group:${platform}:${channelId}`;
229
- const discountGroups = await this.loadJSON(this.DISCOUNT_GROUPS_FILE);
463
+ async performDiscountCheck() {
464
+ const logger = this.ctx.logger('booth-discount');
230
465
 
231
- if (!discountGroups[groupKey]) {
232
- discountGroups[groupKey] = true;
233
- await this.saveJSON(this.DISCOUNT_GROUPS_FILE, discountGroups);
234
- return true;
466
+ if (this._running) {
467
+ logger.info('上次检查仍在运行,跳过本次检查');
468
+ return;
235
469
  }
236
- return false;
237
- }
238
-
239
- async removeDiscountGroupSubscription(platform, channelId) {
240
- const groupKey = `group:${platform}:${channelId}`;
241
- const discountGroups = await this.loadJSON(this.DISCOUNT_GROUPS_FILE);
242
470
 
243
- if (discountGroups[groupKey]) {
244
- delete discountGroups[groupKey];
245
- await this.saveJSON(this.DISCOUNT_GROUPS_FILE, discountGroups);
246
- return true;
471
+ this._running = true;
472
+ try {
473
+ logger.info('开始检查折扣商品');
474
+ this.cleanupExpiredCache();
475
+ const discountItems = await this.searchDiscountItems();
476
+
477
+ if (discountItems.length > 0) {
478
+ await this.notifyDiscountGroups(discountItems);
479
+ } else {
480
+ logger.info('未找到折扣商品');
481
+ this.lastDiscountItems = [];
482
+ }
483
+ } catch (error) {
484
+ logger.error('检查失败:', error.message);
485
+ } finally {
486
+ this._running = false;
247
487
  }
248
- return false;
488
+ }
489
+
490
+ dispose() {
491
+ this._intervalHandles.forEach(h => {
492
+ try { clearInterval(h); } catch (e) {}
493
+ });
494
+ this._intervalHandles = [];
249
495
  }
250
496
  }
251
497
 
package/lib/index.js CHANGED
@@ -36,21 +36,25 @@ const commandHandlerDiscord = require('./command-handler-discord');
36
36
 
37
37
  var name = "koishi-plugin-booth-get";
38
38
  var inject = ["puppeteer"];
39
-
40
39
  var Config = import_koishi.Schema.object({
41
- loadTimeout: import_koishi.Schema.natural().role("ms").description("加载页面的最长时间").default(import_koishi.Time.second * 10),
42
- idleTimeout: import_koishi.Schema.natural().role("ms").description("等待页面空闲的最长时间").default(import_koishi.Time.second * 30),
43
- proxyServer: import_koishi.Schema.string().description("代理服务器地址").default(""),
44
- enableR18Check: import_koishi.Schema.boolean().description("启用R18内容检测").default(true),
45
- 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++++"]).hidden(),
46
- updateInterval: import_koishi.Schema.natural().description("检测订阅更新间隔(分钟)").default(30),
47
- enableDiscountTracking: import_koishi.Schema.boolean().description("启用折扣商品自动追踪").default(true),
48
- discountCheckInterval: import_koishi.Schema.natural().description("折扣检测间隔(分钟)").default(60),
49
- maxDiscountPushCount: import_koishi.Schema.number().min(1).max(50).description("每次最大推送商品数量").default(5),
50
- targetTags: import_koishi.Schema.array(import_koishi.Schema.string()).description("目标标签").default(["VRChat", "3Dモデル", "Avatar", "VRM"]).hidden(),
51
- targetGroups: import_koishi.Schema.array(import_koishi.Schema.string()).description("折扣商品推送目标群组“ID”").default([]),
52
- enableDiscordCommands: import_koishi.Schema.boolean().description("是否启用 Discord 专用指令(启用后仅在 Discord 平台生效)").default(false)
53
- }).description("booth-get");
40
+ loadTimeout: import_koishi.Schema.natural().role("ms").description("页面加载超时").default(10000),
41
+ idleTimeout: import_koishi.Schema.natural().role("ms").description("页面空闲超时").default(30000),
42
+ proxyServer: import_koishi.Schema.string().description("代理地址").default(""),
43
+
44
+ enableR18Check: import_koishi.Schema.boolean().description("启用R18检测").default(true),
45
+ r18Tags: import_koishi.Schema.array(import_koishi.Schema.string()).description("R18标签").default(["r18", "18禁"]).hidden(),
46
+
47
+ updateInterval: import_koishi.Schema.natural().description("订阅更新间隔").default(30),
48
+
49
+ enableDiscountTracking: import_koishi.Schema.boolean().description("启用折扣追踪").default(true),
50
+ discountCheckInterval: import_koishi.Schema.natural().description("折扣检查间隔").default(60),
51
+ maxDiscountPushCount: import_koishi.Schema.number().min(1).max(20).description("最大推送数量").default(10),
52
+ targetGroups: import_koishi.Schema.array(import_koishi.Schema.string()).description("目标群组 (格式: 频道ID 或 平台:频道ID)").default([]),
53
+
54
+ targetTags: import_koishi.Schema.array(import_koishi.Schema.string()).description("搜索标签").default(["VRChat"]).hidden(),
55
+
56
+ enableDiscordCommands: import_koishi.Schema.boolean().description("启用Discord指令").default(false)
57
+ }).description("booth-get配置");
54
58
 
55
59
  function apply(ctx, config) {
56
60
  const logger = ctx.logger("booth-get");
@@ -63,13 +67,21 @@ function apply(ctx, config) {
63
67
  subscriptionManager,
64
68
  discountTracker
65
69
  });
66
- commandHandlerDiscord.init(ctx, config, {
67
- cardGenerator,
68
- subscriptionManager,
69
- discountTracker
70
- });
71
70
 
72
- logger.info('BOOTH插件已启动');
71
+ if (config.enableDiscordCommands) {
72
+ commandHandlerDiscord.init(ctx, config, {
73
+ cardGenerator,
74
+ subscriptionManager,
75
+ discountTracker,
76
+ commandHandler
77
+ });
78
+ }
79
+
80
+ if (config.enableDiscountTracking && config.targetGroups.length === 0) {
81
+ logger.warn('折扣追踪已启用但未配置群组');
82
+ }
83
+
84
+ logger.info('BOOTH插件启动');
73
85
  }
74
86
 
75
87
  __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": "6.0.0-beta.3",
4
+ "version": "6.0.0",
5
5
  "contributors": [
6
6
  "rixiang <1148147857@qq.com>"
7
7
  ],