kiku-agent 1.0.11 → 1.2.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.
Files changed (221) hide show
  1. package/README.md +300 -495
  2. package/package.json +12 -11
  3. package/public/fonts/GoogleSans-Bold.ttf +0 -0
  4. package/public/fonts/GoogleSans-Bold.woff2 +0 -0
  5. package/public/fonts/GoogleSans-BoldItalic.ttf +0 -0
  6. package/public/fonts/GoogleSans-Italic.ttf +0 -0
  7. package/public/fonts/GoogleSans-Latin-400.woff2 +0 -0
  8. package/public/fonts/GoogleSans-Latin-500.woff2 +0 -0
  9. package/public/fonts/GoogleSans-Latin-600.woff2 +0 -0
  10. package/public/fonts/GoogleSans-Latin-700.woff2 +0 -0
  11. package/public/fonts/GoogleSans-Medium.ttf +0 -0
  12. package/public/fonts/GoogleSans-Medium.woff2 +0 -0
  13. package/public/fonts/GoogleSans-Regular.ttf +0 -0
  14. package/public/fonts/GoogleSans-Regular.woff2 +0 -0
  15. package/public/fonts/GoogleSans-Thai-400.woff2 +0 -0
  16. package/public/fonts/GoogleSans-Thai-500.woff2 +0 -0
  17. package/public/fonts/GoogleSans-Thai-600.woff2 +0 -0
  18. package/public/fonts/GoogleSans-Thai-700.woff2 +0 -0
  19. package/public/fonts/NotoSansThai-Bold.woff2 +0 -0
  20. package/public/fonts/NotoSansThai-Regular.woff2 +0 -0
  21. package/public/fonts/google-sans-official.css +900 -0
  22. package/public/images/0040jbadgy1id6am4yxqfj61s82dn1kx02.jpg +0 -0
  23. package/public/images/0040jbadgy1id6am708pbj62c0340qv602.jpg +0 -0
  24. package/public/images/0040jbadgy1id6amb81uqj62c0340x6q02.jpg +0 -0
  25. package/public/images/0040jbadgy1id6amcqte3j62c0340qv602.jpg +0 -0
  26. package/public/images/0040jbadgy1id6amdg3xcj62b232wqv502.jpg +0 -0
  27. package/public/images/0040jbadgy1id6amexv23j620v2pau0x02.jpg +0 -0
  28. package/public/images/0040jbadly8icq3if7twwj60qy0qy0uc02.jpg +0 -0
  29. package/public/images/008ET7Legy1ig9s89nri5j30i20n1gn4.jpg +0 -0
  30. package/public/images/008ET7Legy1ig9s8a2lmlj30i20ny409.jpg +0 -0
  31. package/public/images/008ET7Legy1ig9s8ay1hsj30i20nk3zv.jpg +0 -0
  32. package/public/images/008ET7Legy1ig9s8bab6sj30i20o7go1.jpg +0 -0
  33. package/public/images/008ET7Legy1ig9s8blqsxj30i20oiq4r.jpg +0 -0
  34. package/public/images/008ET7Legy1ig9s8cbxkzj30i20mxmzh.jpg +0 -0
  35. package/public/images/douyin-update/thumbs//346/212/226/351/237/2632026820-399915.jpg +0 -0
  36. package/public/images/douyin-update/thumbs//346/212/226/351/237/2632026820-424041.jpg +0 -0
  37. package/public/images/douyin-update/thumbs//346/212/226/351/237/2632026820-588166.jpg +0 -0
  38. package/public/images/douyin-update/thumbs//346/212/226/351/237/2632026820-672198.jpg +0 -0
  39. package/public/images/douyin-update/thumbs//346/212/226/351/237/2632026820-700522.jpg +0 -0
  40. package/public/images/douyin-update/thumbs//346/212/226/351/237/2632026820-839722.jpg +0 -0
  41. package/public/images/douyin-update/thumbs//346/212/226/351/237/2632026825-460586.jpg +0 -0
  42. package/public/images/douyin-update/thumbs//346/212/226/351/237/263202694-689407.jpg +0 -0
  43. package/public/images/douyin-update//346/212/226/351/237/2632026820-399915.mp4 +0 -0
  44. package/public/images/douyin-update//346/212/226/351/237/2632026820-424041.mp4 +0 -0
  45. package/public/images/douyin-update//346/212/226/351/237/2632026820-588166.mp4 +0 -0
  46. package/public/images/douyin-update//346/212/226/351/237/2632026820-672198.mp4 +0 -0
  47. package/public/images/douyin-update//346/212/226/351/237/2632026820-700522.mp4 +0 -0
  48. package/public/images/douyin-update//346/212/226/351/237/2632026820-839722.mp4 +0 -0
  49. package/public/images/douyin-update//346/212/226/351/237/2632026825-460586.mp4 +0 -0
  50. package/public/images/douyin-update//346/212/226/351/237/263202694-689407.mp4 +0 -0
  51. package/public/images/general/0040jbadgy1id6am4yxqfj61s82dn1kx02.jpg +0 -0
  52. package/public/images/general/0040jbadgy1id6am708pbj62c0340qv602.jpg +0 -0
  53. package/public/images/general/0040jbadgy1id6amb81uqj62c0340x6q02.jpg +0 -0
  54. package/public/images/general/0040jbadgy1id6amcqte3j62c0340qv602.jpg +0 -0
  55. package/public/images/general/0040jbadgy1id6amdg3xcj62b232wqv502.jpg +0 -0
  56. package/public/images/general/0040jbadgy1id6amexv23j620v2pau0x02.jpg +0 -0
  57. package/public/images/general/0040jbadly8icq3if7twwj60qy0qy0uc02.jpg +0 -0
  58. package/public/images/general/008ET7Legy1ig9s89nri5j30i20n1gn4.jpg +0 -0
  59. package/public/images/general/008ET7Legy1ig9s8a2lmlj30i20ny409.jpg +0 -0
  60. package/public/images/general/008ET7Legy1ig9s8ay1hsj30i20nk3zv.jpg +0 -0
  61. package/public/images/general/008ET7Legy1ig9s8bab6sj30i20o7go1.jpg +0 -0
  62. package/public/images/general/008ET7Legy1ig9s8blqsxj30i20oiq4r.jpg +0 -0
  63. package/public/images/general/008ET7Legy1ig9s8cbxkzj30i20mxmzh.jpg +0 -0
  64. package/public/images/general/jujingyi_portrait.jpg +0 -0
  65. package/public/images/jujingyi_portrait.jpg +0 -0
  66. package/public/images/series/a-millenniums-wait/poster.jpg +0 -0
  67. package/public/images/series/beauty-of-resilience/poster.jpg +0 -0
  68. package/public/images/series/detective-samoyeds/poster.jpg +0 -0
  69. package/public/images/series/everyday/poster.jpg +0 -0
  70. package/public/images/series/fu-rong-ancient-painting/poster.jpg +0 -0
  71. package/public/images/series/in-a-class-of-her-own/poster.jpg +0 -0
  72. package/public/images/series/in-blossom/poster.jpg +0 -0
  73. package/public/images/series/legend-of-yunxi/poster.jpg +0 -0
  74. package/public/images/series/lets-fight/poster-2.jpg +0 -0
  75. package/public/images/series/lets-fight/poster.jpg +0 -0
  76. package/public/images/series/love-emergency/poster.jpg +0 -0
  77. package/public/images/series/love-under-the-full-moon/poster.jpg +0 -0
  78. package/public/images/series/money-bastion/poster.jpg +0 -0
  79. package/public/images/series/mr-swimmer/poster.jpg +0 -0
  80. package/public/images/series/novoland-castle-in-the-sky/poster.jpg +0 -0
  81. package/public/images/series/please-give-me-a-pair-of-wings/poster.jpg +0 -0
  82. package/public/images/series/rebirth-for-you/poster.jpg +0 -0
  83. package/public/images/series/sword-and-fairy-4/poster.jpg +0 -0
  84. package/public/images/series/the-blooms-at-ruyi-pavilion/poster.jpg +0 -0
  85. package/public/images/series/the-eternal-fragrance/poster-2.jpg +0 -0
  86. package/public/images/series/the-eternal-fragrance/poster.jpg +0 -0
  87. package/public/images/series/the-fallen-flowers/poster.jpg +0 -0
  88. package/public/images/series/the-legend-of-white-snake/poster.jpg +0 -0
  89. package/public/images/series/veil-of-shadows/poster.jpg +0 -0
  90. package/public/images/series/wanhua-sin/poster-2.jpg +0 -0
  91. package/public/images/series/wanhua-sin/poster.jpg +0 -0
  92. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkj10yogj62u03s0npe02.jpg +0 -0
  93. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkj2pdvyj61li25jkjl02.jpg +0 -0
  94. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkj71177j62672w9e8302.jpg +0 -0
  95. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkj7twpfj62192197rw02.jpg +0 -0
  96. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkj8jfu8j62c03401kx02.jpg +0 -0
  97. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkj9dhdsj622k2l77wh02.jpg +0 -0
  98. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkja31s4j62bw3407r802.jpg +0 -0
  99. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkjcf331j61402ey1kx02.jpg +0 -0
  100. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkjdx8gqj62602pihdt02.jpg +0 -0
  101. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkjfvxnzj62432n57wh02.jpg +0 -0
  102. package/public/images/weibo-update/8-14-17-16/0040jbadgy1ig3ixniv2yj627y4fxb2b02.jpg +0 -0
  103. package/public/images/weibo-update/8-14-17-16/0040jbadgy1ig3ixsvbdaj625k2vfu1102.jpg +0 -0
  104. package/public/images/weibo-update/8-14-17-16/0040jbadgy1ig3ixxxdaoj62c03501l202.jpg +0 -0
  105. package/public/images/weibo-update/8-14-17-16/0040jbadgy1ig3ixzeef9j62c0340npd02.jpg +0 -0
  106. package/public/images/weibo-update/8-14-17-16/0040jbadgy1ig3iy0vs6tj626t4rbhdu02.jpg +0 -0
  107. package/public/images/weibo-update/8-14-17-16/0040jbadgy1ig3iy21wwuj62bt34hx6q02.jpg +0 -0
  108. package/public/images/weibo-update/8-8-17-39/0040jbadgy1ifwly4wr85j62z629y7wh02.jpg +0 -0
  109. package/public/images/weibo-update/8-8-17-39/0040jbadgy1ifwly67oilj623u2t5npd02.jpg +0 -0
  110. package/public/images/weibo-update/8-8-17-39/0040jbadgy1ifwly9qtw8j62c0340u0x02.jpg +0 -0
  111. package/public/images/weibo-update/8-8-17-39/0040jbadgy1ifwlyem4m4j63402c01kz02.jpg +0 -0
  112. package/public/images/weibo-update/8-8-17-39/0040jbadgy1ifwlygkoq4j627s2yeavd02.jpg +0 -0
  113. package/public/images/weibo-update/8-8-17-39/0040jbadgy1ifwlyjh0daj62bw340nm102.jpg +0 -0
  114. package/public/images/weibo-update/8-8-17-39/0040jbadgy1ifwlylejlhj62bw3401kx02.jpg +0 -0
  115. package/public/images/weibo-update/today/0040jbadgy1igaiaa8b7fj627j2y67wh02.jpg +0 -0
  116. package/public/images/weibo-update/today/0040jbadgy1igaiagw3ezj61s02ebu0x02.jpg +0 -0
  117. package/public/images/weibo-update/today/0040jbadgy1igaiaivng3j61rv2ebx6p02.jpg +0 -0
  118. package/public/images/weibo-update/today/0040jbadgy1igaiajuixvj62bw340qv502.jpg +0 -0
  119. package/public/images/weibo-update/today/0040jbadgy1igaiapyartj62bw340hdt02.jpg +0 -0
  120. package/public/images/weibo-update/today/0040jbadgy1igaibkwu4ij61vy2iq1kx02.jpg +0 -0
  121. package/public/images/weibo-update/today/0040jbadgy1igaic1fvkpj61u92eo1ky02.jpg +0 -0
  122. package/public/images/weibo-update/today/0040jbadgy1igaic3cruhj62822ywb2902.jpg +0 -0
  123. package/public/manifest.json +25 -0
  124. package/public/mcp-logo-128.png +0 -0
  125. package/public/mcp-logo-64.png +0 -0
  126. package/public/mcp-logo.svg +16 -0
  127. package/server/agent/agentCore.js +542 -74
  128. package/server/agent/appPaths.js +1 -0
  129. package/server/agent/audioPlayer.js +4 -2
  130. package/server/agent/citations.js +294 -0
  131. package/server/agent/clarificationAnalytics.js +477 -0
  132. package/server/agent/clarifyingQuestions.js +1089 -0
  133. package/server/agent/cli.js +549 -125
  134. package/server/agent/commandFeatures.js +1 -0
  135. package/server/agent/commandRegistry.js +946 -879
  136. package/server/agent/commands.js +546 -272
  137. package/server/agent/config.js +581 -412
  138. package/server/agent/contextCompactor.js +2 -2
  139. package/server/agent/debate.js +235 -0
  140. package/server/agent/eventBus.js +3 -0
  141. package/server/agent/feedbackLearning.js +217 -0
  142. package/server/agent/llmClient.js +512 -394
  143. package/server/agent/mcpClient.js +182 -32
  144. package/server/agent/memory.js +283 -224
  145. package/server/agent/modelManager.js +79 -39
  146. package/server/agent/profiles.js +178 -0
  147. package/server/agent/publicTools.js +22 -0
  148. package/server/agent/queryDetails.js +68 -8
  149. package/server/agent/quizData.js +398 -384
  150. package/server/agent/research.js +121 -79
  151. package/server/agent/sanitizer.js +104 -13
  152. package/server/agent/session.js +319 -11
  153. package/server/agent/subagent.js +8 -3
  154. package/server/agent/suggestionTui.js +294 -0
  155. package/server/agent/suggestions.js +69 -3
  156. package/server/agent/telemetry.js +119 -0
  157. package/server/agent/terminalImage.js +146 -27
  158. package/server/agent/theme.js +17 -0
  159. package/server/agent/toolRouter.js +16 -1
  160. package/server/agent/tools.js +424 -225
  161. package/server/agent/ui.js +486 -64
  162. package/server/agent/version.js +2 -4
  163. package/server/agent/webSearch.js +111 -11
  164. package/server/agent/youtubeAuth.js +24 -6
  165. package/server/apiRouter.js +4222 -0
  166. package/server/mcp/data/discography.js +25 -16
  167. package/server/mcp/data/gallery.js +308 -20
  168. package/server/mcp/data/profile.js +74 -44
  169. package/server/mcp/data/timeline.js +1 -1
  170. package/server/mcp/data/works.js +51 -17
  171. package/server/mcp/dataAdapter.js +5 -3
  172. package/server/mcp/handler.js +5 -11
  173. package/server/mcp/prompts.js +86 -0
  174. package/server/mcp/security.js +18 -15
  175. package/server/mcp/server.js +7 -2
  176. package/server/mcp/tools.js +66 -2
  177. package/server/web-chat/core/browserLauncher.js +18 -0
  178. package/server/web-chat/core/connection.js +194 -0
  179. package/server/web-chat/core/conversationContext.js +82 -0
  180. package/server/web-chat/core/directCatalogEngine.js +196 -0
  181. package/server/web-chat/core/dramaKnowledge.js +142 -0
  182. package/server/web-chat/core/errorHandler.js +51 -0
  183. package/server/web-chat/core/followUpEngine.js +182 -0
  184. package/server/web-chat/core/futureEventGuard.js +80 -0
  185. package/server/web-chat/core/memorySnapshot.js +12 -0
  186. package/server/web-chat/core/security.js +146 -0
  187. package/server/web-chat/core/toolInspector.js +52 -0
  188. package/server/web-chat/router.js +120 -0
  189. package/server/web-chat/routes/chatRoute.js +397 -0
  190. package/server/web-chat/routes/clarifyConfigRoute.js +98 -0
  191. package/server/web-chat/routes/clarifyRoute.js +83 -0
  192. package/server/web-chat/routes/compareRoute.js +79 -0
  193. package/server/web-chat/routes/exportRoute.js +59 -0
  194. package/server/web-chat/routes/feedbackRoute.js +57 -0
  195. package/server/web-chat/routes/newsRoute.js +33 -0
  196. package/server/web-chat/routes/quizRoute.js +11 -0
  197. package/server/web-chat/routes/staticRoute.js +103 -0
  198. package/server/web-chat/routes/statusRoute.js +52 -0
  199. package/server/web-chat/routes/suggestRoute.js +77 -0
  200. package/server/web-chat/routes/timelineRoute.js +34 -0
  201. package/server/web-chat/routes/verifyRoute.js +57 -0
  202. package/server/web-chat/server.js +116 -0
  203. package/server/web-chat/ui/htmlBuilder.js +91 -0
  204. package/server/web-chat/ui/previews/agentBubble.preview.html +134 -0
  205. package/server/web-chat/ui/scripts/agentBubbleSystem.js +320 -0
  206. package/server/web-chat/ui/scripts/client.js +8397 -0
  207. package/server/web-chat/ui/scripts/kikuDb.js +177 -0
  208. package/server/web-chat/ui/scripts/suggestionsData.js +118 -0
  209. package/server/web-chat/ui/scripts/webchatI18n.js +291 -0
  210. package/server/web-chat/ui/styles/main.css +8568 -0
  211. package/server/web-chat/ui/templates/body.html +796 -0
  212. package/server/webChatHtml.js +1 -0
  213. package/server/webChatServer.js +6 -0
  214. package/src/data/discographyData.js +892 -866
  215. package/src/data/galleryData.js +1805 -561
  216. package/src/data/profileData.js +79 -2
  217. package/src/data/socialUpdatesData.js +106 -47
  218. package/src/data/timelineData.js +1 -1
  219. package/src/data/worksData.js +25 -4
  220. package/tokens.css +97 -0
  221. package/server/agent/updateManager.js +0 -456
@@ -0,0 +1,4222 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { profileData } from '../src/data/profileData.js';
5
+ import { worksData } from '../src/data/worksData.js';
6
+ import { discographyData } from '../src/data/discographyData.js';
7
+ import { musicVideoData } from '../src/data/musicVideoData.js';
8
+ import { brandsData } from '../src/data/brandsData.js';
9
+ import { socialUpdatesData } from '../src/data/socialUpdatesData.js';
10
+ import { galleryData } from '../src/data/galleryData.js';
11
+ import { awardsData } from '../src/data/awardsData.js';
12
+ import { timelineData } from '../src/data/timelineData.js';
13
+ import { PACKAGE_VERSION } from './agent/version.js';
14
+ import { QUIZ_CORRECT_ANSWERS } from './agent/quizData.js';
15
+
16
+ function loadEnv() {
17
+ try {
18
+ const envPath = path.resolve(process.cwd(), '.env');
19
+ if (fs.existsSync(envPath)) {
20
+ const content = fs.readFileSync(envPath, 'utf8');
21
+ for (const line of content.split('\n')) {
22
+ const trimmed = line.trim();
23
+ if (!trimmed || trimmed.startsWith('#')) continue;
24
+ const eqIdx = trimmed.indexOf('=');
25
+ if (eqIdx > 0) {
26
+ const key = trimmed.slice(0, eqIdx).trim();
27
+ const val = trimmed.slice(eqIdx + 1).trim();
28
+ if (key && !(key in process.env)) {
29
+ process.env[key] = val;
30
+ }
31
+ }
32
+ }
33
+ }
34
+ } catch {}
35
+ }
36
+ loadEnv();
37
+
38
+ const rateLimitStore = new Map();
39
+ const RATE_LIMIT_WINDOW_MS = 60 * 1000;
40
+ const RATE_LIMIT_ANONYMOUS = 60;
41
+ const RATE_LIMIT_API_KEY = 1000;
42
+
43
+ const rateLimitCleanupInterval = setInterval(() => {
44
+ const now = Date.now();
45
+ for (const [id, record] of rateLimitStore) {
46
+ if (now > record.resetTime + RATE_LIMIT_WINDOW_MS) {
47
+ rateLimitStore.delete(id);
48
+ }
49
+ }
50
+ }, 60000);
51
+ rateLimitCleanupInterval.unref?.();
52
+
53
+ function getSupabaseConfig() {
54
+ const url = process.env.SUPABASE_URL || '';
55
+ const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || '';
56
+ const anonKey = process.env.SUPABASE_ANON_KEY || '';
57
+ const isConfigured = Boolean(url && (serviceKey || anonKey));
58
+ return { url, serviceKey, anonKey, isConfigured };
59
+ }
60
+
61
+ const masterApiKeys = new Set([
62
+ ...(process.env.KIKU_MASTER_KEYS || '').split(',').map(s => s.trim()).filter(Boolean),
63
+ ...(process.env.KIKU_MASTER_KEY ? [process.env.KIKU_MASTER_KEY.trim()] : []),
64
+ ]);
65
+
66
+ const issuedApiKeys = new Set([
67
+ ...(process.env.KIKU_API_KEYS || '').split(',').map(s => s.trim()).filter(Boolean),
68
+ ...(process.env.KIKU_VIP_KEYS || '').split(',').map(s => s.trim()).filter(Boolean),
69
+ ...masterApiKeys
70
+ ]);
71
+ const agentExecutionKeys = new Set([
72
+ ...(process.env.KIKU_AGENT_KEYS || '').split(',').map(s => s.trim()).filter(Boolean),
73
+ ...masterApiKeys,
74
+ ]);
75
+
76
+ const apiKeyCache = new Map();
77
+ const activeQuizSessions = new Map();
78
+ const consumedQuizTokens = new Set();
79
+ const MIN_QUIZ_DURATION_MS = 2500;
80
+
81
+ const API_KEY_PEPPER = process.env.API_KEY_PEPPER || process.env.KIKU_KEY_PEPPER || crypto.randomBytes(32).toString('hex');
82
+ const QUIZ_HMAC_SECRET = process.env.QUIZ_SECRET || process.env.KIKU_QUIZ_SECRET || crypto.randomBytes(32).toString('hex');
83
+
84
+ function isMasterKey(rawKey) {
85
+ if (!rawKey || typeof rawKey !== 'string') return false;
86
+ const trimmed = rawKey.trim();
87
+ if (masterApiKeys.has(trimmed)) return true;
88
+ return false;
89
+ }
90
+
91
+ function hashApiKey(rawKey) {
92
+ if (!rawKey || typeof rawKey !== 'string') return '';
93
+ return crypto.createHmac('sha256', API_KEY_PEPPER).update(rawKey.trim()).digest('hex');
94
+ }
95
+
96
+ function hashApiKeyLegacy(rawKey) {
97
+ if (!rawKey || typeof rawKey !== 'string') return '';
98
+ return crypto.createHash('sha256').update(rawKey.trim()).digest('hex');
99
+ }
100
+
101
+ async function validateApiKey(rawKey) {
102
+ if (!rawKey || typeof rawKey !== 'string') return null;
103
+ const trimmed = rawKey.trim();
104
+ const hash = hashApiKey(trimmed);
105
+ const legacyHash = hashApiKeyLegacy(trimmed);
106
+
107
+ const cached = apiKeyCache.get(hash) || apiKeyCache.get(legacyHash);
108
+ if (cached && Date.now() < cached.validUntil) {
109
+ return cached.isActive ? { tier: cached.tier, rateLimit: cached.rateLimit } : null;
110
+ }
111
+
112
+ if (issuedApiKeys.has(trimmed) || isMasterKey(trimmed)) {
113
+ let tier = 'Developer';
114
+ if (isMasterKey(trimmed)) tier = 'Master VIP';
115
+ else if (trimmed.startsWith('kiku_vip_')) tier = 'Developer VIP';
116
+
117
+ const result = { tier, rateLimit: RATE_LIMIT_API_KEY };
118
+ apiKeyCache.set(hash, { ...result, isActive: true, validUntil: Date.now() + 300000 });
119
+ return result;
120
+ }
121
+
122
+ const { url: supabaseUrl, serviceKey: supabaseServiceKey, anonKey: supabaseAnonKey, isConfigured } = getSupabaseConfig();
123
+ if (!isConfigured) {
124
+ return null;
125
+ }
126
+
127
+ try {
128
+ const apiKeyHeader = supabaseServiceKey || supabaseAnonKey;
129
+ const controller = new AbortController();
130
+ const timeoutId = setTimeout(() => controller.abort(), 2500);
131
+
132
+ const res = await fetch(`${supabaseUrl}/rest/v1/api_keys?or=(key_hash.eq.${hash},key_hash.eq.${legacyHash})&is_active=eq.true&select=tier,rate_limit_per_min,expires_at`, {
133
+ headers: {
134
+ 'apikey': apiKeyHeader,
135
+ 'Authorization': `Bearer ${apiKeyHeader}`,
136
+ 'Content-Type': 'application/json'
137
+ },
138
+ signal: controller.signal
139
+ });
140
+ clearTimeout(timeoutId);
141
+
142
+ if (res.ok) {
143
+ const rows = await res.json();
144
+ if (Array.isArray(rows) && rows.length > 0) {
145
+ const row = rows[0];
146
+ if (row.expires_at && new Date(row.expires_at).getTime() < Date.now()) {
147
+ apiKeyCache.set(hash, { isActive: false, validUntil: Date.now() + 60000 });
148
+ return null;
149
+ }
150
+ let tier = 'Developer';
151
+ if (row.tier === 'master_vip' || row.tier === 'Master VIP') tier = 'Master VIP';
152
+ else if (row.tier === 'developer_vip' || row.tier === 'Developer VIP') tier = 'Developer VIP';
153
+
154
+ const rateLimit = Number(row.rate_limit_per_min) || RATE_LIMIT_API_KEY;
155
+ const result = { tier, rateLimit };
156
+ apiKeyCache.set(hash, { ...result, isActive: true, validUntil: Date.now() + 300000 });
157
+ return result;
158
+ }
159
+ }
160
+ apiKeyCache.set(hash, { isActive: false, validUntil: Date.now() + 60000 });
161
+ return null;
162
+ } catch {
163
+ if (issuedApiKeys.has(trimmed) || isMasterKey(trimmed)) {
164
+ const tier = isMasterKey(trimmed) ? 'Master VIP' : (trimmed.startsWith('kiku_vip_') ? 'Developer VIP' : 'Developer');
165
+ return { tier, rateLimit: RATE_LIMIT_API_KEY };
166
+ }
167
+ return null;
168
+ }
169
+ }
170
+
171
+ async function saveApiKeyToSupabase({ apiKey, tier = 'developer_vip', rateLimit = 1000 }) {
172
+ const hash = hashApiKey(apiKey);
173
+ const prefix = apiKey.slice(0, 16);
174
+
175
+ issuedApiKeys.add(apiKey);
176
+ apiKeyCache.set(hash, {
177
+ tier: tier === 'developer_vip' ? 'Developer VIP' : (tier === 'master_vip' ? 'Master VIP' : 'Developer'),
178
+ rateLimit,
179
+ isActive: true,
180
+ validUntil: Date.now() + 300000
181
+ });
182
+
183
+ const { url: supabaseUrl, serviceKey: supabaseServiceKey, anonKey: supabaseAnonKey, isConfigured } = getSupabaseConfig();
184
+ if (!isConfigured) return;
185
+
186
+ try {
187
+ const apiKeyHeader = supabaseServiceKey || supabaseAnonKey;
188
+ await fetch(`${supabaseUrl}/rest/v1/api_keys`, {
189
+ method: 'POST',
190
+ headers: {
191
+ 'apikey': apiKeyHeader,
192
+ 'Authorization': `Bearer ${apiKeyHeader}`,
193
+ 'Content-Type': 'application/json',
194
+ 'Prefer': 'resolution=merge-duplicates'
195
+ },
196
+ body: JSON.stringify({
197
+ key_prefix: prefix,
198
+ key_hash: hash,
199
+ tier,
200
+ rate_limit_per_min: rateLimit,
201
+ is_active: true
202
+ })
203
+ });
204
+ } catch {}
205
+ }
206
+
207
+
208
+ function createQuizChallengeToken() {
209
+ const now = Date.now();
210
+ for (const [token, session] of activeQuizSessions) {
211
+ if (now > session.expiresAt || session.isClaimed) activeQuizSessions.delete(token);
212
+ }
213
+ while (activeQuizSessions.size >= 2000) {
214
+ activeQuizSessions.delete(activeQuizSessions.keys().next().value);
215
+ }
216
+ const expiresAt = now + (15 * 60 * 1000);
217
+ const nonce = crypto.randomBytes(8).toString('hex');
218
+ const payload = `${now}:${expiresAt}:${nonce}`;
219
+ const signature = crypto.createHmac('sha256', QUIZ_HMAC_SECRET).update(payload).digest('hex');
220
+ const token = `kiku_chal_${Buffer.from(payload).toString('base64url')}.${signature}`;
221
+ activeQuizSessions.set(token, { createdAt: now, expiresAt, isClaimed: false });
222
+ return { challengeToken: token, expiresAt: new Date(expiresAt).toISOString() };
223
+ }
224
+
225
+ function verifyQuizChallengeToken(token) {
226
+ if (!token || typeof token !== 'string' || !token.startsWith('kiku_chal_')) return false;
227
+ if (consumedQuizTokens.has(token)) return false;
228
+ const session = activeQuizSessions.get(token);
229
+ if (!session || session.isClaimed || Date.now() > session.expiresAt) return false;
230
+
231
+ const raw = token.slice('kiku_chal_'.length);
232
+ const dotIdx = raw.lastIndexOf('.');
233
+ if (dotIdx === -1) return false;
234
+
235
+ const b64Payload = raw.slice(0, dotIdx);
236
+ const signature = raw.slice(dotIdx + 1);
237
+
238
+ try {
239
+ const payload = Buffer.from(b64Payload, 'base64url').toString('utf8');
240
+ const expectedSig = crypto.createHmac('sha256', QUIZ_HMAC_SECRET).update(payload).digest('hex');
241
+ const sigBuf = Buffer.from(signature, 'hex');
242
+ const expBuf = Buffer.from(expectedSig, 'hex');
243
+ if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
244
+ return false;
245
+ }
246
+
247
+ const [createdAtStr, expiresAtStr] = payload.split(':');
248
+ const createdAt = Number(createdAtStr);
249
+ const expiresAt = Number(expiresAtStr);
250
+ const now = Date.now();
251
+
252
+ if (isNaN(createdAt) || isNaN(expiresAt)) return false;
253
+ if (createdAt !== session.createdAt || expiresAt !== session.expiresAt) return false;
254
+ if (now > expiresAt) return false;
255
+ if (now - createdAt < MIN_QUIZ_DURATION_MS) return false;
256
+
257
+ return true;
258
+ } catch {
259
+ return false;
260
+ }
261
+ }
262
+
263
+ function consumeQuizChallengeToken(token) {
264
+ if (!token || typeof token !== 'string') return;
265
+ consumedQuizTokens.add(token);
266
+ const session = activeQuizSessions.get(token);
267
+ if (session) {
268
+ session.isClaimed = true;
269
+ }
270
+ activeQuizSessions.delete(token);
271
+ if (consumedQuizTokens.size > 5000) {
272
+ const oldest = consumedQuizTokens.values().next().value;
273
+ if (oldest) consumedQuizTokens.delete(oldest);
274
+ }
275
+ }
276
+
277
+
278
+ function verifyQuizAnswers(answers) {
279
+ if (!Array.isArray(answers) || answers.length < QUIZ_CORRECT_ANSWERS.length) return false;
280
+
281
+ const answeredQuestions = new Set();
282
+ let correctCount = 0;
283
+
284
+ for (const a of answers) {
285
+ const qIdx = Number(a.qIndex);
286
+ const aIdx = Number(a.answerIndex);
287
+ if (isNaN(qIdx) || isNaN(aIdx)) return false;
288
+ if (qIdx < 0 || qIdx >= QUIZ_CORRECT_ANSWERS.length) return false;
289
+ if (answeredQuestions.has(qIdx)) continue;
290
+ answeredQuestions.add(qIdx);
291
+ if (aIdx === QUIZ_CORRECT_ANSWERS[qIdx]) correctCount++;
292
+ }
293
+
294
+ return correctCount === QUIZ_CORRECT_ANSWERS.length;
295
+ }
296
+
297
+
298
+ function parseJsonBody(req, maxBytes = 65536) {
299
+ return new Promise((resolve) => {
300
+ if (req.body && typeof req.body === 'object') return resolve(req.body);
301
+ if (typeof req.body === 'string') {
302
+ try { return resolve(JSON.parse(req.body)); } catch { return resolve({}); }
303
+ }
304
+ let body = '';
305
+ let bytesReceived = 0;
306
+ req.on('data', chunk => {
307
+ bytesReceived += chunk.length;
308
+ if (bytesReceived > maxBytes) {
309
+ req.destroy();
310
+ return resolve({});
311
+ }
312
+ body += chunk;
313
+ });
314
+ req.on('end', () => {
315
+ try { resolve(body ? JSON.parse(body) : {}); } catch { resolve({}); }
316
+ });
317
+ req.on('error', () => resolve({}));
318
+ });
319
+ }
320
+
321
+ function checkRateLimit(clientIdentifier, customMax = RATE_LIMIT_ANONYMOUS) {
322
+ const now = Date.now();
323
+ let record = rateLimitStore.get(clientIdentifier);
324
+
325
+ if (!record || now > record.resetTime) {
326
+ record = {
327
+ count: 1,
328
+ resetTime: now + RATE_LIMIT_WINDOW_MS
329
+ };
330
+ rateLimitStore.set(clientIdentifier, record);
331
+ return {
332
+ allowed: true,
333
+ limit: customMax,
334
+ remaining: customMax - 1,
335
+ reset: Math.ceil(record.resetTime / 1000),
336
+ retryAfter: 0
337
+ };
338
+ }
339
+
340
+ record.count += 1;
341
+ const remaining = Math.max(0, customMax - record.count);
342
+ const isAllowed = record.count <= customMax;
343
+ const retryAfter = Math.ceil((record.resetTime - now) / 1000);
344
+
345
+ return {
346
+ allowed: isAllowed,
347
+ limit: customMax,
348
+ remaining,
349
+ reset: Math.ceil(record.resetTime / 1000),
350
+ retryAfter: isAllowed ? 0 : retryAfter
351
+ };
352
+ }
353
+
354
+ async function checkDistributedRateLimit(clientIdentifier, maxLimit = RATE_LIMIT_ANONYMOUS) {
355
+ const upstashUrl = process.env.UPSTASH_REDIS_REST_URL;
356
+ const upstashToken = process.env.UPSTASH_REDIS_REST_TOKEN;
357
+
358
+ if (upstashUrl && upstashToken) {
359
+ try {
360
+ const now = Date.now();
361
+ const windowKey = `ratelimit:${clientIdentifier}:${Math.floor(now / RATE_LIMIT_WINDOW_MS)}`;
362
+ const controller = new AbortController();
363
+ const timeoutId = setTimeout(() => controller.abort(), 1200);
364
+
365
+ const res = await fetch(`${upstashUrl}/pipeline`, {
366
+ method: 'POST',
367
+ headers: {
368
+ Authorization: `Bearer ${upstashToken}`,
369
+ 'Content-Type': 'application/json'
370
+ },
371
+ body: JSON.stringify([
372
+ ['INCR', windowKey],
373
+ ['EXPIRE', windowKey, 120]
374
+ ]),
375
+ signal: controller.signal
376
+ });
377
+ clearTimeout(timeoutId);
378
+
379
+ if (res.ok) {
380
+ const results = await res.json();
381
+ const count = Number(results[0]?.result) || 1;
382
+ const remaining = Math.max(0, maxLimit - count);
383
+ const resetTime = Math.ceil((Math.floor(now / RATE_LIMIT_WINDOW_MS) + 1) * 60);
384
+ const isAllowed = count <= maxLimit;
385
+ return {
386
+ allowed: isAllowed,
387
+ limit: maxLimit,
388
+ remaining,
389
+ reset: resetTime,
390
+ retryAfter: isAllowed ? 0 : Math.max(1, resetTime - Math.floor(now / 1000)),
391
+ storage: 'upstash-redis'
392
+ };
393
+ }
394
+ } catch {
395
+
396
+ }
397
+ }
398
+
399
+ const inMem = checkRateLimit(clientIdentifier, maxLimit);
400
+ return { ...inMem, storage: 'in-memory' };
401
+ }
402
+
403
+ function escapeHtml(str) {
404
+ if (typeof str !== 'string') return '';
405
+ return str
406
+ .replace(/&/g, '&amp;')
407
+ .replace(/</g, '&lt;')
408
+ .replace(/>/g, '&gt;')
409
+ .replace(/"/g, '&quot;')
410
+ .replace(/'/g, '&#39;');
411
+ }
412
+
413
+ function sanitizeSearchQuery(query, maxLength = 100) {
414
+ if (!query || typeof query !== 'string') return '';
415
+ return query.trim().slice(0, maxLength);
416
+ }
417
+
418
+ function safeErrorMessage(err, fallback = 'Internal Server Error') {
419
+ if (process.env.NODE_ENV === 'development') {
420
+ return err?.message || fallback;
421
+ }
422
+ return fallback;
423
+ }
424
+
425
+ function extractApiKey(req) {
426
+ if (!req || !req.headers) return null;
427
+ const headerKey = req.headers['x-api-key'];
428
+ if (headerKey && typeof headerKey === 'string') return headerKey.trim();
429
+
430
+ const authHeader = req.headers['authorization'];
431
+ if (authHeader && typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
432
+ return authHeader.slice(7).trim();
433
+ }
434
+
435
+ return null;
436
+ }
437
+
438
+ function extractProviderApiKey(req) {
439
+ const value = req?.headers?.['x-provider-api-key'];
440
+ return typeof value === 'string' ? value.trim().slice(0, 4096) : '';
441
+ }
442
+
443
+ function resolveClientIp(req) {
444
+ const directIp = req.socket?.remoteAddress || req.connection?.remoteAddress || '127.0.0.1';
445
+ const trustProxy = process.env.VERCEL === '1' || process.env.KIKU_TRUST_PROXY === 'true';
446
+ if (!trustProxy) return String(directIp);
447
+ const forwarded = req.headers['x-forwarded-for'];
448
+ return typeof forwarded === 'string' && forwarded.trim()
449
+ ? forwarded.split(',')[0].trim()
450
+ : String(directIp);
451
+ }
452
+
453
+ function applySecurityHeaders(res, isHtml = false) {
454
+ const allowedOrigins = (process.env.KIKU_CORS_ORIGINS || process.env.CORS_ALLOWED_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean);
455
+ const corsOrigin = allowedOrigins.includes('*') ? '*' : allowedOrigins[0];
456
+
457
+ if (corsOrigin) res.setHeader('Access-Control-Allow-Origin', corsOrigin);
458
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, HEAD');
459
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, X-API-Key, X-Provider-API-Key, Accept, sec-ch-ua, sec-ch-ua-mobile, sec-ch-ua-platform, Cache-Control');
460
+ res.setHeader('X-Content-Type-Options', 'nosniff');
461
+ res.setHeader('X-Frame-Options', 'DENY');
462
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
463
+ res.setHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
464
+ res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
465
+
466
+ if (isHtml) {
467
+ res.setHeader(
468
+ 'Content-Security-Policy',
469
+ "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: https:; connect-src 'self' https://*.supabase.co https://*.upstash.io;"
470
+ );
471
+ }
472
+ }
473
+
474
+ function sendJson(res, statusCode, payload, headers = {}) {
475
+ res.statusCode = statusCode;
476
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
477
+ applySecurityHeaders(res, false);
478
+
479
+ for (const [key, val] of Object.entries(headers)) {
480
+ res.setHeader(key, val);
481
+ }
482
+
483
+ res.end(JSON.stringify(payload, null, 2));
484
+ }
485
+
486
+ function getCleanDiscography() {
487
+ const map = new Map();
488
+
489
+ const allTracks = [
490
+ ...(discographyData.latestReleases || []).map(track => ({ track, catalogType: 'latest' })),
491
+ ...(discographyData.ostList || []).map(track => ({ track, catalogType: 'ost' })),
492
+ ...(discographyData.singles || []).map(track => ({ track, catalogType: 'singles' }))
493
+ ];
494
+
495
+ for (const { track, catalogType } of allTracks) {
496
+ const key = track.titleCn || track.titleTh;
497
+ const existing = map.get(key);
498
+ if (existing) {
499
+ if (!existing.catalogTypes.includes(catalogType)) existing.catalogTypes.push(catalogType);
500
+ existing.isNew ||= !!track.isNew || catalogType === 'latest';
501
+ } else {
502
+ map.set(key, {
503
+ id: track.id,
504
+ titleTh: track.titleTh,
505
+ titleCn: track.titleCn,
506
+ titleEn: track.titleEn || track.titleTh || track.titleCn,
507
+ year: track.year,
508
+ work: track.work || "Solo Single",
509
+ duration: track.duration || track.trackDuration || "3:30",
510
+ youtubeId: track.youtubeId,
511
+ theme: track.theme || "Pop / Ballad",
512
+ isNew: !!track.isNew || catalogType === 'latest',
513
+ catalogTypes: [catalogType]
514
+ });
515
+ }
516
+ }
517
+
518
+ return Array.from(map.values()).sort((a, b) => b.year - a.year);
519
+ }
520
+
521
+ function getOpenApiSpec(baseUrl = "https://jujingyi-kikuuu.vercel.app") {
522
+ return {
523
+ openapi: "3.0.3",
524
+ info: {
525
+ title: "Ju Jingyi (鞠婧祎) Official Platform REST API & Kiku AI Agent",
526
+ version: PACKAGE_VERSION,
527
+ description: "Official public REST API and Kiku AI Agent platform (npm: kiku-agent) providing high-precision datasets, autonomous ReAct agent CLI, and MCP server for Ju Jingyi's filmography, discography, 4K official music videos, commercial endorsements, career milestones (2013–2026), honors & awards, social media feeds, and global fuzzy cross-search.",
528
+ termsOfService: `${baseUrl}/api-docs`,
529
+ contact: {
530
+ name: "Ju Jingyi Fan Platform Developer Team",
531
+ url: baseUrl,
532
+ email: "contact@jujingyi-kikuuu.vercel.app"
533
+ },
534
+ license: {
535
+ name: "Creative Commons Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)",
536
+ url: "https://creativecommons.org/licenses/by-nc/4.0/"
537
+ }
538
+ },
539
+ servers: [
540
+ {
541
+ url: `${baseUrl}/api/v1`,
542
+ description: "Active Current Server (Auto-detected)"
543
+ },
544
+ {
545
+ url: "https://jujingyi-kikuuu.vercel.app/api/v1",
546
+ description: "Production Vercel Cloud Server"
547
+ },
548
+ {
549
+ url: "http://localhost:3000/api/v1",
550
+ description: "Local Node.js Development Server"
551
+ }
552
+ ],
553
+ tags: [
554
+ { name: "System", description: "API status, health checks, rate limits, and specifications" },
555
+ { name: "Stats", description: "Aggregated platform statistics and metric counts" },
556
+ { name: "Profile", description: "Artist biography, talents, physical stats, and official social accounts" },
557
+ { name: "Fandom", description: "Miju fan-community details, official colors, and fan history" },
558
+ { name: "Quotes", description: "Famous quotes, life philosophy, and mottos by Ju Jingyi" },
559
+ { name: "Works", description: "Filmography catalog, historical & modern dramas, movies, and character roles" },
560
+ { name: "Music", description: "Deduplicated discography, OSTs, studio singles, and 4K music videos" },
561
+ { name: "Commercial", description: "Commercial endorsements, luxury partnerships, and Global Ambassador roles" },
562
+ { name: "Milestones", description: "Career timeline chronology from SNH48 debut (2013) to solo era (2026)" },
563
+ { name: "Media", description: "Curated high-resolution photo gallery and visual archives" },
564
+ { name: "Honors", description: "Awards, SNH48 General Election records, and industry accolades" },
565
+ { name: "Social", description: "Live social feed cards from Douyin, Weibo, and Studio channels" },
566
+ { name: "Search", description: "Global fuzzy search engine spanning all 9 data categories" },
567
+ { name: "Auth", description: "Developer API keys generation for high-rate quota (1,000 req/min)" }
568
+ ],
569
+ paths: {
570
+ "/health": {
571
+ get: {
572
+ tags: ["System"],
573
+ summary: "API Health & Environment Detection",
574
+ description: "Returns API operational status, uptime, detected environment (Localhost vs Production), and active endpoints count.",
575
+ operationId: "getHealthStatus",
576
+ responses: {
577
+ "200": {
578
+ description: "System is operational",
579
+ content: {
580
+ "application/json": {
581
+ schema: { $ref: "#/components/schemas/HealthResponse" }
582
+ }
583
+ }
584
+ }
585
+ }
586
+ }
587
+ },
588
+ "/quiz/challenge": {
589
+ get: {
590
+ tags: ["System"],
591
+ summary: "Request Anti-Cheat Quiz Challenge Token",
592
+ description: "Generates a one-time cryptographic challenge token valid for 15 minutes to secure VIP key claims.",
593
+ operationId: "getQuizChallengeToken",
594
+ responses: {
595
+ "200": {
596
+ description: "Fresh challenge token generated",
597
+ content: {
598
+ "application/json": {
599
+ schema: {
600
+ type: "object",
601
+ properties: {
602
+ status: { type: "string", example: "success" },
603
+ code: { type: "integer", example: 200 },
604
+ data: {
605
+ type: "object",
606
+ properties: {
607
+ challengeToken: { type: "string", example: "kiku_chal_1a2b3c4d5e6f..." },
608
+ expiresAt: { type: "string", format: "date-time" },
609
+ totalQuestions: { type: "integer", example: 15 }
610
+ }
611
+ }
612
+ }
613
+ }
614
+ }
615
+ }
616
+ }
617
+ }
618
+ }
619
+ },
620
+ "/stats": {
621
+ get: {
622
+ tags: ["Stats"],
623
+ summary: "Platform & Artist Overview Statistics",
624
+ description: "Aggregates numerical statistics including total drama works, discography tracks, 4K MVs, brand endorsements, awards, and social followers.",
625
+ operationId: "getPlatformStats",
626
+ responses: {
627
+ "200": {
628
+ description: "Aggregated statistical overview",
629
+ content: {
630
+ "application/json": {
631
+ schema: { $ref: "#/components/schemas/StatsResponse" }
632
+ }
633
+ }
634
+ }
635
+ }
636
+ }
637
+ },
638
+ "/profile": {
639
+ get: {
640
+ tags: ["Profile"],
641
+ summary: "Artist Profile, Talents & Social Links",
642
+ description: "Provides Ju Jingyi's full biographical data, multilingual names, birth date, physical attributes, musical talents, and official verified social media links.",
643
+ operationId: "getProfileData",
644
+ responses: {
645
+ "200": {
646
+ description: "Artist profile details",
647
+ content: {
648
+ "application/json": {
649
+ schema: { $ref: "#/components/schemas/ProfileData" }
650
+ }
651
+ }
652
+ }
653
+ }
654
+ }
655
+ },
656
+ "/fandom": {
657
+ get: {
658
+ tags: ["Fandom"],
659
+ summary: "Miju Fan Community Profile",
660
+ description: "Returns Miju (蜜橘) fan-community data, official support colors (Sky Blue, Sakura Pink, Gold), slogan, and community history.",
661
+ operationId: "getFandomProfile",
662
+ responses: {
663
+ "200": {
664
+ description: "Fandom details",
665
+ content: {
666
+ "application/json": {
667
+ schema: { $ref: "#/components/schemas/FandomData" }
668
+ }
669
+ }
670
+ }
671
+ }
672
+ }
673
+ },
674
+ "/quotes": {
675
+ get: {
676
+ tags: ["Quotes"],
677
+ summary: "Famous Quotes & Mottos",
678
+ description: "Returns iconic inspirational quotes spoken by Ju Jingyi in Chinese, English, and Thai.",
679
+ operationId: "getQuotes",
680
+ responses: {
681
+ "200": {
682
+ description: "List of inspirational quotes",
683
+ content: {
684
+ "application/json": {
685
+ schema: {
686
+ type: "object",
687
+ properties: {
688
+ status: { type: "string", example: "success" },
689
+ code: { type: "integer", example: 200 },
690
+ data: {
691
+ type: "array",
692
+ items: { $ref: "#/components/schemas/Quote" }
693
+ }
694
+ }
695
+ }
696
+ }
697
+ }
698
+ }
699
+ }
700
+ }
701
+ },
702
+ "/character-roles": {
703
+ get: {
704
+ tags: ["Works"],
705
+ summary: "Iconic Drama Character Roles",
706
+ description: "Returns all dramatic characters portrayed by Ju Jingyi with character bios, drama associations, personalities, and iconic traits.",
707
+ operationId: "getCharacterRoles",
708
+ responses: {
709
+ "200": {
710
+ description: "List of dramatic character roles",
711
+ content: {
712
+ "application/json": {
713
+ schema: {
714
+ type: "object",
715
+ properties: {
716
+ status: { type: "string", example: "success" },
717
+ code: { type: "integer", example: 200 },
718
+ data: {
719
+ type: "array",
720
+ items: { $ref: "#/components/schemas/CharacterRole" }
721
+ }
722
+ }
723
+ }
724
+ }
725
+ }
726
+ }
727
+ }
728
+ }
729
+ },
730
+ "/works": {
731
+ get: {
732
+ tags: ["Works"],
733
+ summary: "Complete Filmography & Dramas",
734
+ description: "Lists all 18 authentic drama and movie records with factual Thai synopses, characters, co-stars, directors, platforms, and OST links.",
735
+ operationId: "getWorks",
736
+ parameters: [
737
+ {
738
+ name: "category",
739
+ in: "query",
740
+ required: false,
741
+ schema: { type: "string", enum: ["historical-drama", "modern-drama", "movie"] },
742
+ description: "Filter by work category"
743
+ },
744
+ {
745
+ name: "year",
746
+ in: "query",
747
+ required: false,
748
+ schema: { type: "integer", example: 2024 },
749
+ description: "Filter by release year"
750
+ },
751
+ {
752
+ name: "search",
753
+ in: "query",
754
+ required: false,
755
+ schema: { type: "string", example: "In Blossom" },
756
+ description: "Search in title or character name"
757
+ }
758
+ ],
759
+ responses: {
760
+ "200": {
761
+ description: "Filtered list of filmography works",
762
+ content: {
763
+ "application/json": {
764
+ schema: {
765
+ type: "object",
766
+ properties: {
767
+ status: { type: "string", example: "success" },
768
+ code: { type: "integer", example: 200 },
769
+ data: {
770
+ type: "array",
771
+ items: { $ref: "#/components/schemas/Work" }
772
+ },
773
+ meta: {
774
+ type: "object",
775
+ properties: {
776
+ totalCount: { type: "integer", example: 18 },
777
+ filteredCount: { type: "integer", example: 1 }
778
+ }
779
+ }
780
+ }
781
+ }
782
+ }
783
+ }
784
+ }
785
+ }
786
+ }
787
+ },
788
+ "/works/{id}": {
789
+ get: {
790
+ tags: ["Works"],
791
+ summary: "Single Work Details",
792
+ description: "Retrieve comprehensive details for a specific drama or movie by ID.",
793
+ operationId: "getWorkById",
794
+ parameters: [
795
+ {
796
+ name: "id",
797
+ in: "path",
798
+ required: true,
799
+ schema: { type: "string", example: "in-blossom" },
800
+ description: "Slug ID of the drama or movie"
801
+ }
802
+ ],
803
+ responses: {
804
+ "200": {
805
+ description: "Work details",
806
+ content: {
807
+ "application/json": {
808
+ schema: {
809
+ type: "object",
810
+ properties: {
811
+ status: { type: "string", example: "success" },
812
+ code: { type: "integer", example: 200 },
813
+ data: { $ref: "#/components/schemas/Work" }
814
+ }
815
+ }
816
+ }
817
+ }
818
+ },
819
+ "404": {
820
+ description: "Work not found",
821
+ content: {
822
+ "application/json": {
823
+ schema: { $ref: "#/components/schemas/ErrorResponse" }
824
+ }
825
+ }
826
+ }
827
+ }
828
+ }
829
+ },
830
+ "/discography": {
831
+ get: {
832
+ tags: ["Music"],
833
+ summary: "Deduplicated Discography Catalog",
834
+ description: "Returns deduplicated catalog of verified singles, albums, and OST tracks sung by Ju Jingyi.",
835
+ operationId: "getDiscography",
836
+ parameters: [
837
+ {
838
+ name: "type",
839
+ in: "query",
840
+ required: false,
841
+ schema: { type: "string", enum: ["latest", "ost", "singles"] },
842
+ description: "Filter by release type"
843
+ },
844
+ {
845
+ name: "year",
846
+ in: "query",
847
+ required: false,
848
+ schema: { type: "integer", example: 2026 },
849
+ description: "Filter by release year"
850
+ },
851
+ {
852
+ name: "search",
853
+ in: "query",
854
+ required: false,
855
+ schema: { type: "string", example: "Like a Silk" },
856
+ description: "Search in song title or drama association"
857
+ }
858
+ ],
859
+ responses: {
860
+ "200": {
861
+ description: "List of music tracks",
862
+ content: {
863
+ "application/json": {
864
+ schema: {
865
+ type: "object",
866
+ properties: {
867
+ status: { type: "string", example: "success" },
868
+ code: { type: "integer", example: 200 },
869
+ data: {
870
+ type: "array",
871
+ items: { $ref: "#/components/schemas/DiscographyTrack" }
872
+ }
873
+ }
874
+ }
875
+ }
876
+ }
877
+ }
878
+ }
879
+ }
880
+ },
881
+ "/discography/{id}": {
882
+ get: {
883
+ tags: ["Music"],
884
+ summary: "Single Discography Track Details",
885
+ description: "Retrieve specific track details by song ID.",
886
+ operationId: "getDiscographyById",
887
+ parameters: [
888
+ {
889
+ name: "id",
890
+ in: "path",
891
+ required: true,
892
+ schema: { type: "string", example: "ost-in-blossom" },
893
+ description: "ID of the discography track"
894
+ }
895
+ ],
896
+ responses: {
897
+ "200": {
898
+ description: "Song details",
899
+ content: {
900
+ "application/json": {
901
+ schema: {
902
+ type: "object",
903
+ properties: {
904
+ status: { type: "string", example: "success" },
905
+ code: { type: "integer", example: 200 },
906
+ data: { $ref: "#/components/schemas/DiscographyTrack" }
907
+ }
908
+ }
909
+ }
910
+ }
911
+ },
912
+ "404": {
913
+ description: "Track not found",
914
+ content: {
915
+ "application/json": {
916
+ schema: { $ref: "#/components/schemas/ErrorResponse" }
917
+ }
918
+ }
919
+ }
920
+ }
921
+ }
922
+ },
923
+ "/mv": {
924
+ get: {
925
+ tags: ["Music"],
926
+ summary: "Official Music Videos & Stage Performances",
927
+ description: "Returns official 4K MVs, drama theme music videos, and live festival performances.",
928
+ operationId: "getMusicVideos",
929
+ parameters: [
930
+ {
931
+ name: "category",
932
+ in: "query",
933
+ required: false,
934
+ schema: { type: "string", enum: ["Official-MV", "OST-Music-Video", "Stage-Performance"] },
935
+ description: "Filter by MV category"
936
+ }
937
+ ],
938
+ responses: {
939
+ "200": {
940
+ description: "List of official music videos",
941
+ content: {
942
+ "application/json": {
943
+ schema: {
944
+ type: "object",
945
+ properties: {
946
+ status: { type: "string", example: "success" },
947
+ code: { type: "integer", example: 200 },
948
+ data: {
949
+ type: "array",
950
+ items: { $ref: "#/components/schemas/MusicVideo" }
951
+ }
952
+ }
953
+ }
954
+ }
955
+ }
956
+ }
957
+ }
958
+ }
959
+ },
960
+ "/brands": {
961
+ get: {
962
+ tags: ["Commercial"],
963
+ summary: "Commercial Endorsements & Brand Partnerships",
964
+ description: "Returns official brand spokesperson roles, luxury fashion partnerships, and beauty ambassadorships.",
965
+ operationId: "getBrandEndorsements",
966
+ parameters: [
967
+ {
968
+ name: "isGlobal",
969
+ in: "query",
970
+ required: false,
971
+ schema: { type: "boolean" },
972
+ description: "Filter for Global Brand Ambassador roles"
973
+ }
974
+ ],
975
+ responses: {
976
+ "200": {
977
+ description: "List of brand endorsements",
978
+ content: {
979
+ "application/json": {
980
+ schema: {
981
+ type: "object",
982
+ properties: {
983
+ status: { type: "string", example: "success" },
984
+ code: { type: "integer", example: 200 },
985
+ data: {
986
+ type: "array",
987
+ items: { $ref: "#/components/schemas/Brand" }
988
+ }
989
+ }
990
+ }
991
+ }
992
+ }
993
+ }
994
+ }
995
+ }
996
+ },
997
+ "/timeline": {
998
+ get: {
999
+ tags: ["Milestones"],
1000
+ summary: "Career Milestones & Chronology (2013–2026)",
1001
+ description: "Chronological milestone entries from SNH48 audition in 2013 to independent solo career in 2026.",
1002
+ operationId: "getTimelineMilestones",
1003
+ parameters: [
1004
+ {
1005
+ name: "era",
1006
+ in: "query",
1007
+ required: false,
1008
+ schema: { type: "string", enum: ["snh48", "solo"] },
1009
+ description: "Filter by career era"
1010
+ }
1011
+ ],
1012
+ responses: {
1013
+ "200": {
1014
+ description: "Timeline records",
1015
+ content: {
1016
+ "application/json": {
1017
+ schema: {
1018
+ type: "object",
1019
+ properties: {
1020
+ status: { type: "string", example: "success" },
1021
+ code: { type: "integer", example: 200 },
1022
+ data: {
1023
+ type: "array",
1024
+ items: { $ref: "#/components/schemas/Milestone" }
1025
+ }
1026
+ }
1027
+ }
1028
+ }
1029
+ }
1030
+ }
1031
+ }
1032
+ }
1033
+ },
1034
+ "/gallery": {
1035
+ get: {
1036
+ tags: ["Media"],
1037
+ summary: "High-Resolution Gallery Photos",
1038
+ description: "Curated high-resolution photography collections, red carpet visuals, and stage photos.",
1039
+ operationId: "getGalleryPhotos",
1040
+ parameters: [
1041
+ {
1042
+ name: "category",
1043
+ in: "query",
1044
+ required: false,
1045
+ schema: { type: "string", enum: ["fashion", "drama", "stage", "portrait"] }
1046
+ },
1047
+ {
1048
+ name: "limit",
1049
+ in: "query",
1050
+ required: false,
1051
+ schema: { type: "integer", default: 12, example: 6 },
1052
+ description: "Maximum number of photos to return"
1053
+ }
1054
+ ],
1055
+ responses: {
1056
+ "200": {
1057
+ description: "Photo collection",
1058
+ content: {
1059
+ "application/json": {
1060
+ schema: {
1061
+ type: "object",
1062
+ properties: {
1063
+ status: { type: "string", example: "success" },
1064
+ code: { type: "integer", example: 200 },
1065
+ data: {
1066
+ type: "array",
1067
+ items: { $ref: "#/components/schemas/GalleryItem" }
1068
+ }
1069
+ }
1070
+ }
1071
+ }
1072
+ }
1073
+ }
1074
+ }
1075
+ }
1076
+ },
1077
+ "/awards": {
1078
+ get: {
1079
+ tags: ["Honors"],
1080
+ summary: "Awards, Honors & Hall of Fame Records",
1081
+ description: "Complete list of music awards, drama honors, fashion accolades, and SNH48 Senbatsu Election championship records.",
1082
+ operationId: "getAwardsAndHonors",
1083
+ responses: {
1084
+ "200": {
1085
+ description: "Awards and honors",
1086
+ content: {
1087
+ "application/json": {
1088
+ schema: {
1089
+ type: "object",
1090
+ properties: {
1091
+ status: { type: "string", example: "success" },
1092
+ code: { type: "integer", example: 200 },
1093
+ data: {
1094
+ type: "array",
1095
+ items: { $ref: "#/components/schemas/Award" }
1096
+ }
1097
+ }
1098
+ }
1099
+ }
1100
+ }
1101
+ }
1102
+ }
1103
+ }
1104
+ },
1105
+ "/social": {
1106
+ get: {
1107
+ tags: ["Social"],
1108
+ summary: "Live Social Media Feed Cards",
1109
+ description: "Returns structured social feed cards from Douyin, Weibo, and Studio channels.",
1110
+ operationId: "getSocialFeeds",
1111
+ parameters: [
1112
+ {
1113
+ name: "platform",
1114
+ in: "query",
1115
+ required: false,
1116
+ schema: { type: "string", enum: ["douyin", "weibo"] },
1117
+ description: "Filter by platform"
1118
+ }
1119
+ ],
1120
+ responses: {
1121
+ "200": {
1122
+ description: "Social feed cards",
1123
+ content: {
1124
+ "application/json": {
1125
+ schema: {
1126
+ type: "object",
1127
+ properties: {
1128
+ status: { type: "string", example: "success" },
1129
+ code: { type: "integer", example: 200 },
1130
+ data: {
1131
+ type: "array",
1132
+ items: { $ref: "#/components/schemas/SocialCard" }
1133
+ }
1134
+ }
1135
+ }
1136
+ }
1137
+ }
1138
+ }
1139
+ }
1140
+ }
1141
+ },
1142
+ "/social/douyin/embed": {
1143
+ get: {
1144
+ tags: ["Social"],
1145
+ summary: "Generate Douyin Official Iframe Player Embed",
1146
+ description: "Generates HTML iframe embed code and share URLs conforming to Open Douyin IFrame Video Player specifications.",
1147
+ operationId: "getDouyinIframeEmbed",
1148
+ parameters: [
1149
+ {
1150
+ name: "itemId",
1151
+ in: "query",
1152
+ required: false,
1153
+ schema: { type: "string", example: "7384920194819283921" },
1154
+ description: "Douyin video item ID"
1155
+ }
1156
+ ],
1157
+ responses: {
1158
+ "200": {
1159
+ description: "Douyin iframe embed response",
1160
+ content: {
1161
+ "application/json": {
1162
+ schema: {
1163
+ type: "object",
1164
+ properties: {
1165
+ status: { type: "string", example: "success" },
1166
+ code: { type: "integer", example: 200 },
1167
+ data: {
1168
+ type: "object",
1169
+ properties: {
1170
+ platform: { type: "string", example: "Douyin (抖音)" },
1171
+ itemId: { type: "string", example: "7384920194819283921" },
1172
+ iframeSrc: { type: "string", example: "https://www.iesdouyin.com/share/video/7384920194819283921/" },
1173
+ iframeHtml: { type: "string" },
1174
+ shareUrl: { type: "string" }
1175
+ }
1176
+ }
1177
+ }
1178
+ }
1179
+ }
1180
+ }
1181
+ }
1182
+ }
1183
+ }
1184
+ },
1185
+ "/social/weibo/live": {
1186
+ get: {
1187
+ tags: ["Social"],
1188
+ summary: "Live Sina Weibo Dynamic Feed",
1189
+ description: "Returns live synchronized Weibo updates matching Open Weibo user_timeline and mobile feed container specifications.",
1190
+ operationId: "getWeiboLiveFeed",
1191
+ responses: {
1192
+ "200": {
1193
+ description: "Live Weibo feed records",
1194
+ content: {
1195
+ "application/json": {
1196
+ schema: {
1197
+ type: "object",
1198
+ properties: {
1199
+ status: { type: "string", example: "success" },
1200
+ code: { type: "integer", example: 200 },
1201
+ data: {
1202
+ type: "object",
1203
+ properties: {
1204
+ platform: { type: "string", example: "Sina Weibo (新浪微博)" },
1205
+ account: { type: "string", example: "鞠婧祎 (Ju Jingyi)" },
1206
+ uid: { type: "string", example: "3669102477" },
1207
+ containerId: { type: "string", example: "1076033669102477" },
1208
+ status: { type: "string", example: "synced" },
1209
+ cards: {
1210
+ type: "array",
1211
+ items: { $ref: "#/components/schemas/SocialCard" }
1212
+ }
1213
+ }
1214
+ }
1215
+ }
1216
+ }
1217
+ }
1218
+ }
1219
+ }
1220
+ }
1221
+ }
1222
+ },
1223
+ "/search": {
1224
+ get: {
1225
+ tags: ["Search"],
1226
+ summary: "Global Cross-Dataset Fuzzy Search",
1227
+ description: "Performs full-text fuzzy search across works, discography, music videos, and timeline milestones.",
1228
+ operationId: "searchGlobal",
1229
+ parameters: [
1230
+ {
1231
+ name: "q",
1232
+ in: "query",
1233
+ required: true,
1234
+ schema: { type: "string", example: "In Blossom" },
1235
+ description: "Search keyword"
1236
+ }
1237
+ ],
1238
+ responses: {
1239
+ "200": {
1240
+ description: "Search results matching query",
1241
+ content: {
1242
+ "application/json": {
1243
+ schema: { $ref: "#/components/schemas/SearchResponse" }
1244
+ }
1245
+ }
1246
+ },
1247
+ "400": {
1248
+ description: "Missing required query parameter q",
1249
+ content: {
1250
+ "application/json": {
1251
+ schema: { $ref: "#/components/schemas/ErrorResponse" }
1252
+ }
1253
+ }
1254
+ }
1255
+ }
1256
+ }
1257
+ },
1258
+ "/keys/generate": {
1259
+ get: {
1260
+ tags: ["Auth"],
1261
+ summary: "Generate Developer API Key (1,000 req/min)",
1262
+ description: "Generates a new Developer API key when called with an existing authorized key.",
1263
+ operationId: "generateApiKey",
1264
+ security: [{ ApiKeyHeader: [] }, { ApiKeyQuery: [] }],
1265
+ responses: {
1266
+ "200": {
1267
+ description: "Newly generated API key",
1268
+ content: {
1269
+ "application/json": {
1270
+ schema: {
1271
+ type: "object",
1272
+ properties: {
1273
+ status: { type: "string", example: "success" },
1274
+ code: { type: "integer", example: 200 },
1275
+ data: {
1276
+ type: "object",
1277
+ properties: {
1278
+ apiKey: { type: "string", example: "kiku_dev_9x2b81_lz39xk" },
1279
+ tier: { type: "string", example: "Developer Tier (1,000 requests / minute)" },
1280
+ usage: { type: "string", example: "Pass via 'X-API-Key' header or '?apiKey=' query param" }
1281
+ }
1282
+ }
1283
+ }
1284
+ }
1285
+ }
1286
+ }
1287
+ },
1288
+ "401": {
1289
+ description: "Missing or invalid API key",
1290
+ content: {
1291
+ "application/json": {
1292
+ schema: { $ref: "#/components/schemas/ErrorResponse" }
1293
+ }
1294
+ }
1295
+ }
1296
+ }
1297
+ }
1298
+ }
1299
+ },
1300
+ components: {
1301
+ securitySchemes: {
1302
+ ApiKeyHeader: {
1303
+ type: "apiKey",
1304
+ in: "header",
1305
+ name: "X-API-Key",
1306
+ description: "Developer API Key passed in request header"
1307
+ },
1308
+ ApiKeyQuery: {
1309
+ type: "apiKey",
1310
+ in: "query",
1311
+ name: "apiKey",
1312
+ description: "Developer API Key passed in query string"
1313
+ }
1314
+ },
1315
+ schemas: {
1316
+ HealthResponse: {
1317
+ type: "object",
1318
+ properties: {
1319
+ status: { type: "string", example: "success" },
1320
+ code: { type: "integer", example: 200 },
1321
+ timestamp: { type: "string", format: "date-time" },
1322
+ data: {
1323
+ type: "object",
1324
+ properties: {
1325
+ apiName: { type: "string", example: "Ju Jingyi Official Chronicle REST API" },
1326
+ version: { type: "string", example: `v${PACKAGE_VERSION}` },
1327
+ status: { type: "string", example: "operational" },
1328
+ environment: { type: "string", example: "production" },
1329
+ isLocal: { type: "boolean", example: false },
1330
+ currentHost: { type: "string", example: "jujingyi-kikuuu.vercel.app" },
1331
+ uptimeSeconds: { type: "integer", example: 3600 },
1332
+ endpointsCount: { type: "integer", example: 18 },
1333
+ tier: { type: "string", example: "Public" },
1334
+ baseUrl: { type: "string", example: "https://jujingyi-kikuuu.vercel.app/api/v1" },
1335
+ documentation: { type: "string", example: "https://jujingyi-kikuuu.vercel.app/api-docs" },
1336
+ openApiSpec: { type: "string", example: "https://jujingyi-kikuuu.vercel.app/api/v1/openapi.json" }
1337
+ }
1338
+ }
1339
+ }
1340
+ },
1341
+ StatsResponse: {
1342
+ type: "object",
1343
+ properties: {
1344
+ status: { type: "string", example: "success" },
1345
+ code: { type: "integer", example: 200 },
1346
+ data: {
1347
+ type: "object",
1348
+ properties: {
1349
+ artist: { type: "string", example: "Ju Jingyi (鞠婧祎)" },
1350
+ actingWorksCount: { type: "integer", example: 18 },
1351
+ discographyTracksCount: { type: "integer", example: 45 },
1352
+ musicVideosCount: { type: "integer", example: 18 },
1353
+ brandEndorsementsCount: { type: "integer", example: 12 },
1354
+ timelineMilestonesCount: { type: "integer", example: 18 },
1355
+ awardsCount: { type: "integer", example: 12 },
1356
+ socialFollowers: {
1357
+ type: "object",
1358
+ properties: {
1359
+ weibo: { type: "string", example: "31.5M+" },
1360
+ douyin: { type: "string", example: "24.8M+" },
1361
+ instagram: { type: "string", example: "3.2M+" }
1362
+ }
1363
+ }
1364
+ }
1365
+ }
1366
+ }
1367
+ },
1368
+ Work: {
1369
+ type: "object",
1370
+ properties: {
1371
+ id: { type: "string", example: "in-blossom" },
1372
+ titleTh: { type: "string", example: "บุปผารักอลวน" },
1373
+ titleCn: { type: "string", example: "花间令" },
1374
+ titleEn: { type: "string", example: "In Blossom" },
1375
+ role: { type: "string", example: "หยางไฉ่เวย / ซ่างกวนจื่อ (Yang Caiwei / Shangguan Zhi)" },
1376
+ characterCn: { type: "string", example: "杨采薇 / 上官芷" },
1377
+ category: { type: "string", example: "historical-drama" },
1378
+ year: { type: "integer", example: 2024 },
1379
+ status: { type: "string", example: "ออกอากาศจบแล้ว (40 ตอน)" },
1380
+ platform: { type: "string", example: "YOUKU" },
1381
+ synopsisTh: { type: "string" },
1382
+ coStars: { type: "array", items: { type: "string" } },
1383
+ director: { type: "string", example: "จงชิง (Zhong Qing)" },
1384
+ milestone: { type: "string", example: "ทำดัชนีความนิยม YOUKU ทะลุ 10,000 รวดเร็วที่สุดของปี 2024" },
1385
+ themeSong: {
1386
+ type: "object",
1387
+ properties: {
1388
+ titleTh: { type: "string", example: "ดั่งแพรไหม (Like a Silk)" },
1389
+ titleCn: { type: "string", example: "《如纱》" },
1390
+ youtubeId: { type: "string", example: "DafSXZZo9AI" }
1391
+ }
1392
+ }
1393
+ }
1394
+ },
1395
+ DiscographyTrack: {
1396
+ type: "object",
1397
+ properties: {
1398
+ id: { type: "string", example: "ost-in-blossom" },
1399
+ titleTh: { type: "string", example: "ดั่งแพรไหม" },
1400
+ titleCn: { type: "string", example: "如纱" },
1401
+ titleEn: { type: "string", example: "Like a Silk" },
1402
+ year: { type: "integer", example: 2024 },
1403
+ work: { type: "string", example: "ซีรีส์ บุปผารักอลวน (In Blossom OST)" },
1404
+ duration: { type: "string", example: "3:42" },
1405
+ youtubeId: { type: "string", example: "DafSXZZo9AI" },
1406
+ theme: { type: "string", example: "เพลงประกอบซีรีส์ (OST)" },
1407
+ isNew: { type: "boolean", example: true },
1408
+ catalogTypes: {
1409
+ type: "array",
1410
+ items: { type: "string", enum: ["latest", "ost", "singles"] },
1411
+ example: ["latest", "ost"]
1412
+ }
1413
+ }
1414
+ },
1415
+ MusicVideo: {
1416
+ type: "object",
1417
+ properties: {
1418
+ id: { type: "string", example: "mv-veil-of-shadows" },
1419
+ titleTh: { type: "string", example: "ไม่ทรมาน (Bu Ku - Veil of Shadows OST)" },
1420
+ titleCn: { type: "string", example: "不苦" },
1421
+ category: { type: "string", example: "OST-Music-Video" },
1422
+ youtubeId: { type: "string", example: "9AeErG03SZw" },
1423
+ duration: { type: "string", example: "3:30" }
1424
+ }
1425
+ },
1426
+ Brand: {
1427
+ type: "object",
1428
+ properties: {
1429
+ id: { type: "string", example: "brand-lanvin" },
1430
+ nameEn: { type: "string", example: "LANVIN Paris" },
1431
+ nameCn: { type: "string", example: "浪凡" },
1432
+ tier: { type: "string", example: "Global Brand Ambassador" },
1433
+ category: { type: "string", example: "High-End Fashion & Haute Couture" },
1434
+ isGlobal: { type: "boolean", example: true }
1435
+ }
1436
+ },
1437
+ Milestone: {
1438
+ type: "object",
1439
+ properties: {
1440
+ year: { type: "integer", example: 2024 },
1441
+ title: { type: "string", example: "In Blossom Phenomenon & Independent Breakthrough" },
1442
+ titleCn: { type: "string", example: "《花间令》全网大爆与个人独立突破" },
1443
+ era: { type: "string", example: "solo" }
1444
+ }
1445
+ },
1446
+ GalleryItem: {
1447
+ type: "object",
1448
+ properties: {
1449
+ id: { type: "string", example: "gal-1" },
1450
+ title: { type: "string", example: "Weibo Night Red Carpet Haute Couture" },
1451
+ category: { type: "string", example: "fashion" },
1452
+ imageUrl: { type: "string" }
1453
+ }
1454
+ },
1455
+ Award: {
1456
+ type: "object",
1457
+ properties: {
1458
+ id: { type: "string", example: "award-snh-1" },
1459
+ year: { type: "integer", example: 2017 },
1460
+ title: { type: "string", example: "SNH48 4th Senbatsu Election Champion (No. 1)" },
1461
+ titleCn: { type: "string", example: "SNH48第四届总选举第一名 (连霸殿堂级)" },
1462
+ category: { type: "string", example: "SNH48 Election Legend" }
1463
+ }
1464
+ },
1465
+ SocialCard: {
1466
+ type: "object",
1467
+ properties: {
1468
+ id: { type: "string", example: "social-douyin-1" },
1469
+ platform: { type: "string", example: "douyin" },
1470
+ authorName: { type: "string", example: "鞠婧祎" },
1471
+ content: { type: "string" },
1472
+ likes: { type: "string", example: "1.8M+" },
1473
+ comments: { type: "string", example: "128K+" }
1474
+ }
1475
+ },
1476
+ ProfileData: {
1477
+ type: "object",
1478
+ properties: {
1479
+ name: {
1480
+ type: "object",
1481
+ properties: {
1482
+ cn: { type: "string", example: "鞠婧祎" },
1483
+ pinyin: { type: "string", example: "Jū Jìngyī" },
1484
+ en: { type: "string", example: "Ju Jingyi (Kiku)" },
1485
+ th: { type: "string", example: "จวีจิ้งอี" }
1486
+ }
1487
+ },
1488
+ birthDate: { type: "string", example: "1994-06-18" },
1489
+ birthPlace: { type: "string", example: "Suining, Sichuan, China (遂宁, 四川)" },
1490
+ zodiac: { type: "string", example: "Gemini / Dog" },
1491
+ height: { type: "string", example: "159 cm" }
1492
+ }
1493
+ },
1494
+ FandomData: {
1495
+ type: "object",
1496
+ properties: {
1497
+ fandomName: { type: "string", example: "Miju (蜜橘)" },
1498
+ fandomNickname: { type: "string", example: "Miju (蜜橘)" },
1499
+ slogan: { type: "string", example: "鞠躬尽瘁,为婧而生 (Devoted with respect, born for Jingyi)" },
1500
+ officialColors: {
1501
+ type: "array",
1502
+ items: {
1503
+ type: "object",
1504
+ properties: {
1505
+ name: { type: "string", example: "Crystal Blue" },
1506
+ hex: { type: "string", example: "#58B2DC" }
1507
+ }
1508
+ }
1509
+ }
1510
+ }
1511
+ },
1512
+ Quote: {
1513
+ type: "object",
1514
+ properties: {
1515
+ quoteCn: { type: "string", example: "无论走得多远,都不要忘记为什么出发。" },
1516
+ quoteTh: { type: "string", example: "ไม่ว่าจะก้าวไปไกลแค่ไหน อย่าลืมว่าเราเริ่มต้นเดินทางเพราะอะไร" },
1517
+ quoteEn: { type: "string", example: "No matter how far you go, never forget why you started." },
1518
+ author: { type: "string", example: "鞠婧祎 (Ju Jingyi)" }
1519
+ }
1520
+ },
1521
+ CharacterRole: {
1522
+ type: "object",
1523
+ properties: {
1524
+ characterNameTh: { type: "string", example: "หานอวิ๋นซี (Han Yunxi)" },
1525
+ characterNameCn: { type: "string", example: "韩芸汐" },
1526
+ dramaTitleTh: { type: "string", example: "หยุนซี หมอพิษหญิงยอดอัจฉริยะ (Legend of Yunxi)" },
1527
+ dramaTitleCn: { type: "string", example: "芸汐传" },
1528
+ year: { type: "integer", example: 2018 },
1529
+ roleDescriptionTh: { type: "string" }
1530
+ }
1531
+ },
1532
+ SearchResponse: {
1533
+ type: "object",
1534
+ properties: {
1535
+ status: { type: "string", example: "success" },
1536
+ code: { type: "integer", example: 200 },
1537
+ data: {
1538
+ type: "object",
1539
+ properties: {
1540
+ works: { type: "array", items: { $ref: "#/components/schemas/Work" } },
1541
+ discography: { type: "array", items: { $ref: "#/components/schemas/DiscographyTrack" } },
1542
+ musicVideos: { type: "array", items: { $ref: "#/components/schemas/MusicVideo" } },
1543
+ timeline: { type: "array", items: { $ref: "#/components/schemas/Milestone" } }
1544
+ }
1545
+ },
1546
+ meta: {
1547
+ type: "object",
1548
+ properties: {
1549
+ query: { type: "string", example: "In Blossom" },
1550
+ totalMatches: { type: "integer", example: 4 }
1551
+ }
1552
+ }
1553
+ }
1554
+ },
1555
+ ErrorResponse: {
1556
+ type: "object",
1557
+ properties: {
1558
+ status: { type: "string", example: "error" },
1559
+ code: { type: "integer", example: 404 },
1560
+ timestamp: { type: "string", format: "date-time" },
1561
+ message: { type: "string", example: "Resource not found" }
1562
+ }
1563
+ }
1564
+ }
1565
+ }
1566
+ };
1567
+ }
1568
+
1569
+ function renderApiDocsHtml() {
1570
+ const openApi = getOpenApiSpec();
1571
+ return `<!DOCTYPE html>
1572
+ <html lang="en" class="scroll-smooth">
1573
+ <head>
1574
+ <meta charset="UTF-8">
1575
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1576
+ <title>Ju Jingyi (鞠婧祎) API v1 Documentation & Interactive Console</title>
1577
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌸</text></svg>">
1578
+ <link rel="preconnect" href="https://fonts.googleapis.com">
1579
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1580
+ <link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;600;700;800;900&family=Noto+Sans+SC:wght@300;400;500;600;700;900&family=Ma+Shan+Zheng&display=swap" rel="stylesheet">
1581
+ <script>
1582
+ (function() {
1583
+ const origWarn = console.warn;
1584
+ console.warn = function(...args) {
1585
+ if (typeof args[0] === 'string' && args[0].includes('cdn.tailwindcss.com')) return;
1586
+ origWarn.apply(console, args);
1587
+ };
1588
+
1589
+ try {
1590
+ const saved = localStorage.getItem('kiku_theme') || localStorage.getItem('theme');
1591
+ const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
1592
+ if (saved === 'dark' || (!saved && prefersDark) || !saved) {
1593
+ document.documentElement.classList.add('dark');
1594
+ } else {
1595
+ document.documentElement.classList.remove('dark');
1596
+ }
1597
+ } catch {}
1598
+ })();
1599
+ </script>
1600
+ <script src="https://cdn.tailwindcss.com"></script>
1601
+ <script>
1602
+ tailwind.config = {
1603
+ darkMode: 'class',
1604
+ theme: {
1605
+ extend: {
1606
+ fontFamily: {
1607
+ sans: ["'Google Sans'", "'Product Sans'", "'Noto Sans SC'", "-apple-system", "BlinkMacSystemFont", "'Segoe UI'", "Roboto", "sans-serif"],
1608
+ display: ["'Google Sans'", "'Product Sans'", "'Noto Sans SC'", "-apple-system", "BlinkMacSystemFont", "sans-serif"],
1609
+ calligraphy: ["'Ma Shan Zheng'", "'Noto Serif SC'", "serif"]
1610
+ }
1611
+ }
1612
+ }
1613
+ };
1614
+ </script>
1615
+ <style>
1616
+ @font-face {
1617
+ font-family: 'Google Sans';
1618
+ font-style: normal;
1619
+ font-weight: 400;
1620
+ font-display: swap;
1621
+ src: url('/fonts/GoogleSans-Thai-400.woff2') format('woff2'),
1622
+ url('/fonts/GoogleSans-Latin-400.woff2') format('woff2'),
1623
+ url('/fonts/GoogleSans-Regular.woff2') format('woff2'),
1624
+ local('Google Sans'), local('Product Sans');
1625
+ }
1626
+ @font-face {
1627
+ font-family: 'Google Sans';
1628
+ font-style: normal;
1629
+ font-weight: 500;
1630
+ font-display: swap;
1631
+ src: url('/fonts/GoogleSans-Thai-500.woff2') format('woff2'),
1632
+ url('/fonts/GoogleSans-Latin-500.woff2') format('woff2'),
1633
+ url('/fonts/GoogleSans-Medium.ttf') format('truetype'),
1634
+ local('Google Sans Medium'), local('Google Sans');
1635
+ }
1636
+ @font-face {
1637
+ font-family: 'Google Sans';
1638
+ font-style: normal;
1639
+ font-weight: 600 900;
1640
+ font-display: swap;
1641
+ src: url('/fonts/GoogleSans-Thai-700.woff2') format('woff2'),
1642
+ url('/fonts/GoogleSans-Latin-700.woff2') format('woff2'),
1643
+ url('/fonts/GoogleSans-Bold.ttf') format('truetype'),
1644
+ local('Google Sans Bold'), local('Google Sans');
1645
+ }
1646
+ html {
1647
+ scroll-behavior: smooth;
1648
+ -webkit-text-size-adjust: 100%;
1649
+ text-rendering: optimizeLegibility;
1650
+ -webkit-font-smoothing: antialiased;
1651
+ -moz-osx-font-smoothing: grayscale;
1652
+ }
1653
+ *, *::before, *::after, html, body {
1654
+ font-family: 'Google Sans', 'Product Sans', 'Noto Sans SC', sans-serif !important;
1655
+ -webkit-font-smoothing: antialiased !important;
1656
+ -moz-osx-font-smoothing: grayscale !important;
1657
+ text-rendering: optimizeLegibility !important;
1658
+ }
1659
+ h1, h2, h3, h4, h5, h6 {
1660
+ letter-spacing: -0.02em;
1661
+ }
1662
+ body {
1663
+ letter-spacing: -0.01em;
1664
+ }
1665
+ .font-sans {
1666
+ font-family: 'Google Sans', 'Product Sans', 'Noto Sans SC', sans-serif !important;
1667
+ }
1668
+ .font-display {
1669
+ font-family: 'Google Sans', 'Product Sans', 'Noto Sans SC', sans-serif !important;
1670
+ }
1671
+ .font-calligraphy {
1672
+ font-family: 'Ma Shan Zheng', 'Noto Serif SC', serif !important;
1673
+ }
1674
+ .font-mono, code, pre {
1675
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
1676
+ }
1677
+ .glass-card { background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(16px); border: 1px solid rgba(244, 63, 94, 0.15); }
1678
+ .glass-dark { background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(20px); border: 1px solid rgba(244, 63, 94, 0.2); }
1679
+ .dark .glass-dark { background: rgba(18, 12, 28, 0.92); backdrop-filter: blur(20px); border: 1px solid rgba(244, 63, 94, 0.25); }
1680
+
1681
+ ::-webkit-scrollbar {
1682
+ width: 8px;
1683
+ height: 8px;
1684
+ }
1685
+ ::-webkit-scrollbar-track {
1686
+ background: #f5f5f4;
1687
+ }
1688
+ .dark ::-webkit-scrollbar-track {
1689
+ background: #09050d;
1690
+ }
1691
+ ::-webkit-scrollbar-thumb {
1692
+ background: #cbd5e1;
1693
+ border-radius: 9999px;
1694
+ border: 2px solid #f5f5f4;
1695
+ }
1696
+ .dark ::-webkit-scrollbar-thumb {
1697
+ background: #4c1d35;
1698
+ border-radius: 9999px;
1699
+ border: 2px solid #09050d;
1700
+ }
1701
+ ::-webkit-scrollbar-thumb:hover {
1702
+ background: #e11d48;
1703
+ }
1704
+ * {
1705
+ scrollbar-width: thin;
1706
+ scrollbar-color: #cbd5e1 #f5f5f4;
1707
+ }
1708
+ .dark * {
1709
+ scrollbar-color: #4c1d35 #09050d;
1710
+ }
1711
+ </style>
1712
+ </head>
1713
+ <body class="bg-[#FFFDF9] text-stone-900 dark:bg-gradient-to-br dark:from-stone-950 dark:via-[#100918] dark:to-stone-950 dark:text-stone-100 min-h-screen transition-colors duration-200">
1714
+
1715
+ <header class="sticky top-0 z-50 bg-white/95 dark:bg-[#0A0512]/90 backdrop-blur-2xl border-b border-rose-200 dark:border-rose-900/40 px-4 sm:px-8 py-3.5 sm:py-4.5 flex items-center justify-between gap-3 shadow-md dark:shadow-xl dark:shadow-black/40 transition-all duration-300">
1716
+ <div class="flex items-center gap-3 sm:gap-4">
1717
+ <a href="/" class="flex items-center gap-3 group select-none transition-transform active:scale-98 text-left">
1718
+ <div class="w-10 h-10 sm:w-12 sm:h-12 rounded-2xl bg-gradient-to-br from-rose-500 via-pink-600 to-amber-400 p-[1.5px] shadow-lg shadow-rose-600/30 group-hover:shadow-rose-600/50 transition-all">
1719
+ <div class="w-full h-full rounded-[14px] bg-rose-50 dark:bg-[#12091c] flex items-center justify-center font-calligraphy text-rose-700 dark:text-rose-300 font-bold text-lg sm:text-xl group-hover:scale-110 transition-transform">
1720
+ 鞠
1721
+ </div>
1722
+ </div>
1723
+ <div class="flex flex-col">
1724
+ <div class="flex items-center gap-2 leading-tight">
1725
+ <span class="font-display font-black text-base sm:text-lg tracking-wider text-stone-900 dark:text-white group-hover:text-rose-600 dark:group-hover:text-rose-200 transition-colors">JU JINGYI</span>
1726
+ <span class="font-calligraphy text-xs sm:text-sm font-semibold text-rose-600 dark:text-rose-400 hidden xs:inline">鞠婧祎</span>
1727
+ </div>
1728
+ <div class="flex items-center gap-2 mt-0.5">
1729
+ <span class="text-[10px] sm:text-[11px] px-2 py-0.5 rounded-full bg-rose-100 dark:bg-rose-950/80 text-rose-800 dark:text-rose-300 font-mono font-bold border border-rose-200 dark:border-rose-800/60 shadow-2xs">
1730
+ REST API v1.0
1731
+ </span>
1732
+ <span class="text-[11px] font-sans text-rose-600/90 dark:text-rose-400/70 hidden md:inline">Open Developer Portal</span>
1733
+ </div>
1734
+ </div>
1735
+ </a>
1736
+ </div>
1737
+
1738
+ <div class="flex items-center gap-2 sm:gap-3 shrink-0">
1739
+ <button type="button" id="theme-toggle-btn" onclick="toggleTheme()" class="px-2.5 sm:px-3.5 py-2 rounded-xl sm:rounded-full bg-rose-50 hover:bg-rose-100 dark:bg-rose-950/60 dark:hover:bg-rose-900/60 border border-rose-200 dark:border-rose-800/60 text-stone-700 dark:text-rose-200 transition-all flex items-center gap-1.5 shadow-xs active:scale-95 cursor-pointer shrink-0" title="สลับโหมด มืด / สว่าง (Toggle Dark/Light Mode)">
1740
+ <svg id="theme-icon-sun" class="w-4 h-4 text-amber-500 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1741
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path>
1742
+ </svg>
1743
+ <svg id="theme-icon-moon" class="w-4 h-4 text-rose-600 block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1744
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
1745
+ </svg>
1746
+ <span id="theme-label" class="text-xs font-bold hidden sm:inline">Theme</span>
1747
+ </button>
1748
+
1749
+ <div class="hidden md:inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full bg-rose-50 dark:bg-rose-950/60 border border-rose-200 dark:border-rose-800/50 text-rose-800 dark:text-rose-200 text-xs font-mono shadow-inner">
1750
+ <span class="relative flex h-2 w-2">
1751
+ <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
1752
+ <span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
1753
+ </span>
1754
+ <span>Live Gateway</span>
1755
+ <span class="text-stone-400 dark:text-stone-500">|</span>
1756
+ <span class="text-rose-600 dark:text-rose-300 font-semibold">60 - 1,000 req/min</span>
1757
+ </div>
1758
+
1759
+ <a href="/api/v1/openapi.json" target="_blank" class="px-3 sm:px-4 py-2 rounded-xl sm:rounded-full bg-rose-50 hover:bg-rose-100 dark:bg-rose-950/60 dark:hover:bg-rose-900/60 border border-rose-200 dark:border-rose-800/60 hover:border-rose-500 text-xs sm:text-sm font-semibold text-rose-800 dark:text-rose-200 hover:text-rose-950 dark:hover:text-white transition-all flex items-center gap-1.5 shadow-xs active:scale-95">
1760
+ <svg class="w-4 h-4 text-rose-500 dark:text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1761
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path>
1762
+ </svg>
1763
+ <span class="hidden sm:inline">OpenAPI 3.0</span>
1764
+ <span class="sm:hidden">Spec</span>
1765
+ </a>
1766
+
1767
+ <a href="/" class="px-3.5 sm:px-5 py-2 rounded-xl sm:rounded-full bg-gradient-to-r from-rose-600 to-pink-600 hover:from-rose-500 hover:to-pink-500 text-xs sm:text-sm font-bold text-white transition-all shadow-md shadow-rose-600/30 hover:shadow-rose-600/50 flex items-center gap-1.5 active:scale-95 shrink-0">
1768
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1769
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
1770
+ </svg>
1771
+ <span class="hidden sm:inline">Back to Website</span>
1772
+ <span class="sm:hidden">Website</span>
1773
+ </a>
1774
+ </div>
1775
+ </header>
1776
+
1777
+ <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12 space-y-8">
1778
+
1779
+ <div class="p-6 sm:p-10 rounded-3xl bg-white dark:bg-stone-900/90 border border-rose-200 dark:border-rose-900/40 relative overflow-hidden space-y-4 shadow-xl dark:shadow-2xl">
1780
+ <div class="absolute -right-16 -top-16 w-80 h-80 rounded-full bg-rose-500/10 dark:bg-rose-600/10 blur-3xl pointer-events-none"></div>
1781
+ <div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-rose-100 dark:bg-rose-950/80 border border-rose-200 dark:border-rose-800/50 text-rose-800 dark:text-rose-300 text-xs font-semibold uppercase tracking-widest">
1782
+ <span>⚡ Public RESTful API v1.0</span>
1783
+ </div>
1784
+ <h1 class="text-3xl sm:text-5xl font-extrabold text-stone-900 dark:text-white tracking-tight">
1785
+ Ju Jingyi (鞠婧祎) <span class="text-transparent bg-clip-text bg-gradient-to-r from-rose-600 via-pink-500 to-amber-500 dark:from-rose-400 dark:via-pink-300 dark:to-amber-200">Developer API</span>
1786
+ </h1>
1787
+ <p class="text-stone-600 dark:text-stone-300 text-sm sm:text-base max-w-3xl leading-relaxed">
1788
+ Access comprehensive structured datasets for Chinese superstar Ju Jingyi, covering 45+ musical releases, complete drama filmography, 4K music videos, commercial endorsements, career milestones, awards, and instant fuzzy cross-domain search.
1789
+ </p>
1790
+
1791
+ <div class="grid grid-cols-2 sm:grid-cols-4 gap-3 pt-2">
1792
+ <div class="p-3.5 rounded-2xl bg-rose-50/70 dark:bg-stone-900/70 border border-rose-200/80 dark:border-stone-800/90 text-center hover:border-rose-400 dark:hover:border-rose-800/40 transition-colors shadow-xs">
1793
+ <div class="text-[11px] font-semibold uppercase tracking-wider text-stone-500 dark:text-stone-400">Base URL</div>
1794
+ <div class="text-rose-700 dark:text-rose-300 text-xs sm:text-sm font-bold truncate mt-1">/api/v1</div>
1795
+ </div>
1796
+ <div class="p-3.5 rounded-2xl bg-rose-50/70 dark:bg-stone-900/70 border border-rose-200/80 dark:border-stone-800/90 text-center hover:border-rose-400 dark:hover:border-rose-800/40 transition-colors shadow-xs">
1797
+ <div class="text-[11px] font-semibold uppercase tracking-wider text-stone-500 dark:text-stone-400">Rate Limit Tier</div>
1798
+ <div class="text-rose-700 dark:text-rose-300 text-xs sm:text-sm font-bold mt-1">60 - 1,000 req/min</div>
1799
+ </div>
1800
+ <div class="p-3.5 rounded-2xl bg-rose-50/70 dark:bg-stone-900/70 border border-rose-200/80 dark:border-stone-800/90 text-center hover:border-rose-400 dark:hover:border-rose-800/40 transition-colors shadow-xs">
1801
+ <div class="text-[11px] font-semibold uppercase tracking-wider text-stone-500 dark:text-stone-400">Response Format</div>
1802
+ <div class="text-rose-700 dark:text-rose-300 text-xs sm:text-sm font-bold mt-1">JSON (UTF-8)</div>
1803
+ </div>
1804
+ <div class="p-3.5 rounded-2xl bg-rose-50/70 dark:bg-stone-900/70 border border-rose-200/80 dark:border-stone-800/90 text-center hover:border-emerald-400 dark:hover:border-emerald-800/40 transition-colors shadow-xs">
1805
+ <div class="text-[11px] font-semibold uppercase tracking-wider text-stone-500 dark:text-stone-400">CORS Support</div>
1806
+ <div class="text-emerald-600 dark:text-emerald-400 text-xs sm:text-sm font-bold mt-1">Enabled (*)</div>
1807
+ </div>
1808
+ </div>
1809
+ </div>
1810
+
1811
+ <div class="space-y-4 pt-1">
1812
+ <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
1813
+ <div class="flex items-center gap-2.5">
1814
+ <div class="w-8 h-8 rounded-xl bg-gradient-to-br from-amber-500/20 to-rose-500/20 border border-amber-500/30 flex items-center justify-center text-amber-600 dark:text-amber-300 font-bold text-sm">
1815
+ ⚡
1816
+ </div>
1817
+ <div>
1818
+ <h2 class="text-xl sm:text-2xl font-bold text-stone-900 dark:text-white tracking-tight">API Access Tiers</h2>
1819
+ <p class="text-xs text-stone-500 dark:text-stone-400">Choose the access level that fits your application requirements</p>
1820
+ </div>
1821
+ </div>
1822
+ <div id="active-tier-badge" class="self-start sm:self-auto inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-emerald-100 dark:bg-emerald-950/80 border border-emerald-300 dark:border-emerald-800/60 text-emerald-800 dark:text-emerald-300 text-xs font-semibold">
1823
+ <span class="w-2 h-2 rounded-full bg-emerald-500 dark:bg-emerald-400 animate-pulse"></span>
1824
+ <span id="tier-badge-label">Public Tier Active (60 req/min)</span>
1825
+ </div>
1826
+ </div>
1827
+
1828
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
1829
+ <div class="p-6 rounded-3xl bg-white dark:bg-stone-900/60 border border-rose-200/80 dark:border-stone-800/80 relative overflow-hidden flex flex-col justify-between space-y-4 hover:border-rose-400 dark:hover:border-stone-700 transition-all shadow-md">
1830
+ <div class="space-y-3">
1831
+ <div class="flex items-center justify-between">
1832
+ <span class="px-3 py-1 rounded-full bg-stone-100 dark:bg-stone-800 text-stone-800 dark:text-stone-300 font-bold text-xs">Public Tier (Free)</span>
1833
+ <span class="font-mono text-stone-500 dark:text-stone-400 text-xs">Default Tier</span>
1834
+ </div>
1835
+ <h3 class="text-2xl font-black text-stone-900 dark:text-white">60 <span class="text-xs font-normal text-stone-500 dark:text-stone-400 font-sans">requests / minute</span></h3>
1836
+ <p class="text-xs text-stone-600 dark:text-stone-300 leading-relaxed">
1837
+ Open public access for fan websites, hobby projects, and exploration. No authentication key or registration required.
1838
+ </p>
1839
+ <ul class="space-y-2 text-xs text-stone-700 dark:text-stone-300">
1840
+ <li class="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">✓ <span class="text-stone-700 dark:text-stone-300">No API Key required</span></li>
1841
+ <li class="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">✓ <span class="text-stone-700 dark:text-stone-300">Full access to all 18+ REST endpoints</span></li>
1842
+ <li class="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">✓ <span class="text-stone-700 dark:text-stone-300">CORS Enabled for browser and frontend apps</span></li>
1843
+ <li class="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">✓ <span class="text-stone-700 dark:text-stone-300">Instant JSON responses</span></li>
1844
+ </ul>
1845
+ </div>
1846
+ <div class="pt-2">
1847
+ <div class="p-2.5 rounded-xl bg-stone-50 dark:bg-stone-950/60 border border-stone-200 dark:border-stone-800/60 text-center text-xs text-emerald-700 dark:text-emerald-400 font-semibold">
1848
+ 🟢 Active & Ready to use out of the box
1849
+ </div>
1850
+ </div>
1851
+ </div>
1852
+
1853
+ <div class="p-6 rounded-3xl bg-gradient-to-br from-rose-50/90 via-amber-50/40 to-white dark:from-stone-900/90 dark:via-rose-950/40 dark:to-amber-950/20 border-2 border-rose-400 dark:border-rose-500/40 relative overflow-hidden flex flex-col justify-between space-y-4 shadow-xl hover:border-rose-500 dark:hover:border-rose-400/60 transition-all">
1854
+ <div class="absolute -right-10 -top-10 w-40 h-40 bg-rose-500/10 rounded-full blur-2xl pointer-events-none"></div>
1855
+ <div class="space-y-3 relative z-10">
1856
+ <div class="flex items-center justify-between">
1857
+ <span class="px-3 py-1 rounded-full bg-gradient-to-r from-rose-600 to-amber-500 text-white font-bold text-xs shadow-xs flex items-center gap-1">
1858
+ 👑 Developer VIP Tier
1859
+ </span>
1860
+ <span class="font-mono text-amber-600 dark:text-amber-300 text-xs font-bold">16x Quota</span>
1861
+ </div>
1862
+ <h3 class="text-2xl font-black text-transparent bg-clip-text bg-gradient-to-r from-rose-600 via-pink-600 to-amber-600 dark:from-rose-300 dark:via-pink-200 dark:to-amber-200">
1863
+ 1,000 <span class="text-xs font-normal text-stone-500 dark:text-stone-400 font-sans">requests / minute</span>
1864
+ </h3>
1865
+ <p class="text-xs text-stone-600 dark:text-stone-300 leading-relaxed">
1866
+ Dedicated high-throughput quota for production bots, research crawlers, AI subagents, and heavy client applications.
1867
+ </p>
1868
+ <ul class="space-y-2 text-xs text-stone-700 dark:text-stone-300">
1869
+ <li class="flex items-center gap-2 text-amber-600 dark:text-amber-300">★ <span class="text-stone-700 dark:text-stone-300">1,000 requests/minute high rate limit</span></li>
1870
+ <li class="flex items-center gap-2 text-amber-600 dark:text-amber-300">★ <span class="text-stone-700 dark:text-stone-300">Exclusive <code>X-API-Key</code> authorization</span></li>
1871
+ <li class="flex items-center gap-2 text-amber-600 dark:text-amber-300">★ <span class="text-stone-700 dark:text-stone-300">Permanent VIP key generation</span></li>
1872
+ <li class="flex items-center gap-2 text-amber-600 dark:text-amber-300">★ <span class="text-stone-700 dark:text-stone-300">Unlocked by passing Ju Jingyi Fan Quiz (15/15)</span></li>
1873
+ </ul>
1874
+ </div>
1875
+
1876
+ <div class="pt-2 relative z-10 space-y-2" id="vip-action-container">
1877
+ <button type="button" id="start-quiz-btn" onclick="openQuizModal()" class="w-full py-3 px-4 rounded-xl bg-gradient-to-r from-rose-600 via-pink-600 to-amber-500 hover:from-rose-500 hover:to-amber-400 font-bold text-sm text-white shadow-lg shadow-rose-600/30 active:scale-98 transition-all flex items-center justify-center gap-2 cursor-pointer group">
1878
+ <span class="group-hover:rotate-12 transition-transform">🌸</span>
1879
+ <span>Take Ju Jingyi Quiz & Unlock VIP Key (15 Questions)</span>
1880
+ </button>
1881
+ <div id="vip-key-display" class="hidden p-3 rounded-xl bg-rose-50 dark:bg-stone-950/90 border border-rose-300 dark:border-rose-800/60 flex items-center justify-between gap-2 shadow-inner">
1882
+ <div class="truncate">
1883
+ <span class="text-[10px] text-stone-500 dark:text-stone-400 block font-mono">YOUR DEVELOPER VIP KEY:</span>
1884
+ <span id="vip-key-val" class="font-mono text-xs text-rose-700 dark:text-rose-300 font-bold truncate"></span>
1885
+ </div>
1886
+ <div class="flex items-center gap-1.5 shrink-0">
1887
+ <button type="button" onclick="copyVipKey()" class="px-2.5 py-1.5 rounded-lg bg-rose-600 hover:bg-rose-500 dark:bg-rose-900/60 dark:hover:bg-rose-800 text-xs font-bold text-white dark:text-rose-200 transition-colors cursor-pointer">
1888
+ Copy
1889
+ </button>
1890
+ <button type="button" onclick="openQuizModal()" class="px-2.5 py-1.5 rounded-lg bg-stone-200 hover:bg-stone-300 dark:bg-stone-800 dark:hover:bg-stone-700 text-xs text-stone-700 dark:text-stone-300 transition-colors cursor-pointer" title="Retake Quiz">
1891
+ Retake
1892
+ </button>
1893
+ </div>
1894
+ </div>
1895
+ </div>
1896
+ </div>
1897
+ </div>
1898
+ </div>
1899
+
1900
+ <!-- Kiku Agent & MCP Ecosystem Showcase -->
1901
+ <div class="p-6 sm:p-8 rounded-3xl bg-gradient-to-br from-rose-50/90 via-white to-pink-50/60 dark:from-stone-900/90 dark:via-[#130b1c] dark:to-stone-900/90 border-2 border-rose-300/80 dark:border-rose-900/60 relative overflow-hidden shadow-xl space-y-6">
1902
+ <div class="absolute -right-20 -bottom-20 w-72 h-72 rounded-full bg-rose-500/10 dark:bg-rose-500/15 blur-3xl pointer-events-none"></div>
1903
+
1904
+ <div class="flex flex-col md:flex-row md:items-center justify-between gap-4 relative z-10">
1905
+ <div class="flex items-start sm:items-center gap-3.5">
1906
+ <div class="w-12 h-12 rounded-2xl bg-gradient-to-br from-rose-500 via-pink-600 to-amber-400 p-0.5 shadow-lg shadow-rose-600/30 flex items-center justify-center shrink-0">
1907
+ <div class="w-full h-full rounded-[14px] bg-rose-50 dark:bg-[#12091c] flex items-center justify-center text-xl">
1908
+ 🌸
1909
+ </div>
1910
+ </div>
1911
+ <div>
1912
+ <div class="flex items-center gap-2 flex-wrap">
1913
+ <span class="text-xs px-2.5 py-0.5 rounded-full bg-rose-100 dark:bg-rose-950 text-rose-800 dark:text-rose-300 font-mono font-bold border border-rose-200 dark:border-rose-800/60">
1914
+ npm package: kiku-agent
1915
+ </span>
1916
+ <span class="text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 dark:bg-emerald-950/80 text-emerald-800 dark:text-emerald-300 font-mono font-bold border border-emerald-200 dark:border-emerald-800/60">
1917
+ v1.1.0 Ready
1918
+ </span>
1919
+ <span class="text-[11px] px-2 py-0.5 rounded-full bg-purple-100 dark:bg-purple-950/80 text-purple-800 dark:text-purple-300 font-mono font-bold border border-purple-200 dark:border-purple-800/60">
1920
+ MCP Protocol
1921
+ </span>
1922
+ </div>
1923
+ <h2 class="text-2xl sm:text-3xl font-extrabold text-stone-900 dark:text-white tracking-tight mt-1">
1924
+ Kiku AI Agent CLI & MCP Server
1925
+ </h2>
1926
+ <p class="text-xs sm:text-sm text-stone-600 dark:text-stone-300 mt-1 max-w-3xl">
1927
+ Need an autonomous AI assistant or want to connect Ju Jingyi's knowledge base to Cursor, Claude Desktop, or VS Code? Use the official <strong>kiku-agent</strong> CLI & <strong>kiku-mcp</strong> server with 45 validated tools, local LM Studio (<code class="font-mono text-xs">google/gemma-4-e4b</code>) or cloud LLMs, and multi-agent debate.
1928
+ </p>
1929
+ </div>
1930
+ </div>
1931
+
1932
+ <div class="flex items-center gap-2 flex-wrap shrink-0">
1933
+ <a href="https://www.npmjs.com/package/kiku-agent" target="_blank" rel="noopener noreferrer" class="px-4 py-2 rounded-xl bg-rose-600 hover:bg-rose-500 text-white font-bold text-xs shadow-md shadow-rose-600/30 flex items-center gap-1.5 transition-all active:scale-95">
1934
+ <svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M1.763 0C.786 0 0 .786 0 1.763v20.474C0 23.214.786 24 1.763 24h20.474c.981 0 1.763-.786 1.763-1.763V1.763C24 .786 23.214 0 22.237 0zM5.13 5.323l13.837.019-.009 13.836h-3.464l.01-10.382h-3.456L12.04 19.17H5.113z"/></svg>
1935
+ <span>View on npm</span>
1936
+ </a>
1937
+ <a href="/chat" class="px-4 py-2 rounded-xl bg-stone-100 hover:bg-stone-200 dark:bg-stone-800 dark:hover:bg-stone-700 text-stone-800 dark:text-stone-100 font-bold text-xs border border-stone-300 dark:border-stone-700 flex items-center gap-1.5 transition-all active:scale-95">
1938
+ <span>💬 Web Chat</span>
1939
+ </a>
1940
+ <a href="https://github.com/ThemeHackers/jujingyi_kikuuu" target="_blank" rel="noopener noreferrer" class="px-4 py-2 rounded-xl bg-stone-100 hover:bg-stone-200 dark:bg-stone-800 dark:hover:bg-stone-700 text-stone-800 dark:text-stone-100 font-bold text-xs border border-stone-300 dark:border-stone-700 flex items-center gap-1.5 transition-all active:scale-95">
1941
+ <span>GitHub</span>
1942
+ </a>
1943
+ </div>
1944
+ </div>
1945
+
1946
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 pt-2 relative z-10">
1947
+ <!-- Agent CLI Card -->
1948
+ <div class="p-5 rounded-2xl bg-white/90 dark:bg-stone-950/70 border border-rose-200/80 dark:border-stone-800/80 flex flex-col justify-between space-y-4 shadow-sm">
1949
+ <div class="space-y-3">
1950
+ <div class="flex items-center justify-between">
1951
+ <div class="flex items-center gap-2">
1952
+ <span class="w-2.5 h-2.5 rounded-full bg-rose-500 animate-pulse"></span>
1953
+ <h3 class="font-bold text-stone-900 dark:text-white text-base">Terminal AI Agent (CLI)</h3>
1954
+ </div>
1955
+ <span class="font-mono text-xs px-2 py-0.5 rounded bg-rose-50 dark:bg-rose-950 text-rose-700 dark:text-rose-300 border border-rose-200 dark:border-rose-800/60 font-semibold">45 Tools</span>
1956
+ </div>
1957
+ <p class="text-xs text-stone-600 dark:text-stone-300 leading-relaxed">
1958
+ Autonomous ReAct agent with dynamic multiline input, live thinking reasoning streams, local/cloud LLM switching, background research jobs, YouTube music streaming, and multi-agent debate.
1959
+ </p>
1960
+
1961
+ <div class="space-y-2 pt-1 font-mono text-xs">
1962
+ <div class="space-y-1">
1963
+ <div class="text-[11px] text-stone-500 dark:text-stone-400 font-sans font-semibold">1. Install Globally or Run Zero-Install:</div>
1964
+ <div class="flex items-center justify-between bg-stone-900 text-rose-200 p-2.5 rounded-xl border border-stone-800 shadow-inner">
1965
+ <code class="truncate">npm install -g kiku-agent</code>
1966
+ <button type="button" onclick="copySnippet('npm install -g kiku-agent', this)" class="text-[11px] px-2 py-0.5 rounded bg-rose-800/80 hover:bg-rose-700 text-white cursor-pointer ml-2 shrink-0">Copy</button>
1967
+ </div>
1968
+ </div>
1969
+
1970
+ <div class="space-y-1">
1971
+ <div class="text-[11px] text-stone-500 dark:text-stone-400 font-sans font-semibold">2. Start Interactive Session:</div>
1972
+ <div class="flex items-center justify-between bg-stone-900 text-rose-200 p-2.5 rounded-xl border border-stone-800 shadow-inner">
1973
+ <code class="truncate">kiku-agent</code>
1974
+ <button type="button" onclick="copySnippet('kiku-agent', this)" class="text-[11px] px-2 py-0.5 rounded bg-rose-800/80 hover:bg-rose-700 text-white cursor-pointer ml-2 shrink-0">Copy</button>
1975
+ </div>
1976
+ </div>
1977
+
1978
+ <div class="space-y-1">
1979
+ <div class="text-[11px] text-stone-500 dark:text-stone-400 font-sans font-semibold">3. One-Shot Question Query (npx):</div>
1980
+ <div class="flex items-center justify-between bg-stone-900 text-rose-200 p-2.5 rounded-xl border border-stone-800 shadow-inner">
1981
+ <code class="truncate text-[11px]">npx kiku-agent --query "Ju Jingyi filmography"</code>
1982
+ <button type="button" onclick="copySnippet('npx kiku-agent --query \\\"Ju Jingyi filmography\\\"', this)" class="text-[11px] px-2 py-0.5 rounded bg-rose-800/80 hover:bg-rose-700 text-white cursor-pointer ml-2 shrink-0">Copy</button>
1983
+ </div>
1984
+ </div>
1985
+ </div>
1986
+ </div>
1987
+ </div>
1988
+
1989
+ <!-- MCP Server Card -->
1990
+ <div class="p-5 rounded-2xl bg-white/90 dark:bg-stone-950/70 border border-purple-200/80 dark:border-purple-900/40 flex flex-col justify-between space-y-4 shadow-sm">
1991
+ <div class="space-y-3">
1992
+ <div class="flex items-center justify-between">
1993
+ <div class="flex items-center gap-2">
1994
+ <span class="w-2.5 h-2.5 rounded-full bg-purple-500 animate-pulse"></span>
1995
+ <h3 class="font-bold text-stone-900 dark:text-white text-base">Model Context Protocol (MCP)</h3>
1996
+ </div>
1997
+ <span class="font-mono text-xs px-2 py-0.5 rounded bg-purple-50 dark:bg-purple-950 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800/60 font-semibold">43 MCP Tools</span>
1998
+ </div>
1999
+ <p class="text-xs text-stone-600 dark:text-stone-300 leading-relaxed">
2000
+ Connect Cursor, Claude Desktop, VS Code, or Antigravity directly to Ju Jingyi's full knowledge base using stdio or Streamable HTTP.
2001
+ </p>
2002
+
2003
+ <div class="space-y-2 pt-1 font-mono text-xs">
2004
+ <div class="space-y-1">
2005
+ <div class="text-[11px] text-stone-500 dark:text-stone-400 font-sans font-semibold">Launch stdio MCP server:</div>
2006
+ <div class="flex items-center justify-between bg-stone-900 text-purple-200 p-2.5 rounded-xl border border-stone-800 shadow-inner">
2007
+ <code class="truncate">kiku-mcp</code>
2008
+ <button type="button" onclick="copySnippet('kiku-mcp', this)" class="text-[11px] px-2 py-0.5 rounded bg-purple-800/80 hover:bg-purple-700 text-white cursor-pointer ml-2 shrink-0">Copy</button>
2009
+ </div>
2010
+ </div>
2011
+
2012
+ <div class="space-y-1">
2013
+ <div class="text-[11px] text-stone-500 dark:text-stone-400 font-sans font-semibold">Remote HTTP MCP Endpoint:</div>
2014
+ <div class="flex items-center justify-between bg-stone-900 text-purple-200 p-2.5 rounded-xl border border-stone-800 shadow-inner">
2015
+ <code class="truncate text-[11px]">POST https://jujingyi-kikuuu.vercel.app/mcp</code>
2016
+ <button type="button" onclick="copySnippet('https://jujingyi-kikuuu.vercel.app/mcp', this)" class="text-[11px] px-2 py-0.5 rounded bg-purple-800/80 hover:bg-purple-700 text-white cursor-pointer ml-2 shrink-0">Copy</button>
2017
+ </div>
2018
+ </div>
2019
+
2020
+ <div class="space-y-1">
2021
+ <div class="text-[11px] text-stone-500 dark:text-stone-400 font-sans font-semibold">Claude Desktop / Cursor config JSON:</div>
2022
+ <div class="bg-stone-900 text-stone-300 p-2.5 rounded-xl border border-stone-800 shadow-inner relative group">
2023
+ <pre class="text-[10px] leading-relaxed overflow-x-auto"><code>{
2024
+ "mcpServers": {
2025
+ "kiku": {
2026
+ "command": "npx",
2027
+ "args": ["-y", "kiku-agent", "kiku-mcp"]
2028
+ }
2029
+ }
2030
+ }</code></pre>
2031
+ <button type="button" onclick="copySnippet('{\\n \\\"mcpServers\\\": {\\n \\\"kiku\\\": {\\n \\\"command\\\": \\\"npx\\\",\\n \\\"args\\\": [\\\"-y\\\", \\\"kiku-agent\\\", \\\"kiku-mcp\\\"]\\n }\\n }\\n}', this)" class="absolute top-2 right-2 text-[10px] px-2 py-0.5 rounded bg-purple-800/80 hover:bg-purple-700 text-white cursor-pointer">Copy</button>
2032
+ </div>
2033
+ </div>
2034
+ </div>
2035
+ </div>
2036
+ </div>
2037
+ </div>
2038
+ </div>
2039
+
2040
+ <div class="space-y-5">
2041
+ <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-4 sm:p-5 rounded-2xl bg-white dark:bg-stone-900/40 border border-rose-200 dark:border-stone-800/80 backdrop-blur-md shadow-md dark:shadow-lg">
2042
+ <div class="flex items-center gap-3">
2043
+ <div class="w-10 h-10 rounded-xl bg-gradient-to-br from-rose-500/20 to-pink-500/20 border border-rose-300 dark:border-rose-500/30 flex items-center justify-center text-rose-600 dark:text-rose-400 shrink-0">
2044
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2045
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
2046
+ </svg>
2047
+ </div>
2048
+ <div>
2049
+ <div class="flex items-center gap-2.5 flex-wrap">
2050
+ <h2 class="text-xl sm:text-2xl font-bold text-stone-900 dark:text-white tracking-tight">Available Endpoints</h2>
2051
+ <span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-rose-100 dark:bg-rose-950/90 text-rose-800 dark:text-rose-300 border border-rose-200 dark:border-rose-800/60 font-mono text-xs font-bold shadow-xs">
2052
+ <span class="w-1.5 h-1.5 rounded-full bg-rose-500"></span>
2053
+ 18 Routes
2054
+ </span>
2055
+ <span class="hidden md:inline-flex text-[11px] px-2.5 py-0.5 rounded-full bg-emerald-100 dark:bg-emerald-950/70 text-emerald-800 dark:text-emerald-300 border border-emerald-300 dark:border-emerald-800/50 font-mono font-medium">
2056
+ 9 Data Domains
2057
+ </span>
2058
+ </div>
2059
+ <p class="text-xs text-stone-500 dark:text-stone-400 mt-0.5">Explore schema definitions, request parameters, and live in-browser testing</p>
2060
+ </div>
2061
+ </div>
2062
+
2063
+ <div class="flex items-center gap-2 self-start sm:self-center">
2064
+ <button type="button" onclick="toggleAllEndpoints()" class="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-stone-100 hover:bg-stone-200 dark:bg-gradient-to-r dark:from-stone-900 dark:via-rose-950/40 dark:to-stone-900 dark:hover:from-rose-950/80 dark:hover:to-rose-900/40 border border-stone-300 dark:border-stone-700/80 dark:hover:border-rose-500/50 text-stone-800 dark:text-stone-200 dark:hover:text-white text-xs font-semibold shadow-xs dark:shadow-md transition-all cursor-pointer active:scale-95 group">
2065
+ <svg class="w-3.5 h-3.5 text-amber-500 dark:text-amber-400 group-hover:rotate-12 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2066
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
2067
+ </svg>
2068
+ <span id="toggle-all-text">⚡ Click to Test Live (Expand All)</span>
2069
+ </button>
2070
+ </div>
2071
+ </div>
2072
+
2073
+ <div class="space-y-4" id="endpoint-list"></div>
2074
+ </div>
2075
+
2076
+ <div id="quiz-modal" class="fixed inset-0 z-50 bg-black/75 dark:bg-black/85 backdrop-blur-md hidden items-center justify-center p-3 sm:p-4">
2077
+ <div class="max-w-2xl w-full rounded-3xl bg-white dark:bg-stone-950 border border-rose-200 dark:border-rose-800/50 shadow-2xl overflow-hidden flex flex-col max-h-[92vh] animate-fadeIn">
2078
+ <div class="p-4 sm:p-5 bg-gradient-to-r from-rose-100 via-white to-amber-50 dark:from-rose-950/80 dark:via-stone-950 dark:to-amber-950/60 border-b border-rose-200 dark:border-rose-900/40 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
2079
+ <div class="flex items-center gap-3">
2080
+ <div class="w-10 h-10 rounded-2xl bg-gradient-to-br from-rose-500 to-amber-500 p-0.5 shadow-md flex items-center justify-center shrink-0">
2081
+ <div class="w-full h-full rounded-[14px] bg-rose-50 dark:bg-stone-950 flex items-center justify-center font-calligraphy text-rose-700 dark:text-rose-300 font-bold text-lg">
2082
+ 鞠
2083
+ </div>
2084
+ </div>
2085
+ <div>
2086
+ <h3 id="quiz-modal-title" class="text-base sm:text-lg font-bold text-stone-900 dark:text-white leading-tight">Ju Jingyi Miju Trivia Challenge 🌸</h3>
2087
+ <p id="quiz-modal-subtitle" class="text-xs text-rose-800 dark:text-rose-300/80">Answer all 15 questions correctly to claim your Developer VIP Key (1,000 req/min)</p>
2088
+ </div>
2089
+ </div>
2090
+
2091
+ <div class="flex items-center justify-between sm:justify-end gap-2 shrink-0">
2092
+ <div class="flex items-center gap-1 p-1 rounded-xl bg-stone-100 dark:bg-stone-900/90 border border-stone-200 dark:border-stone-800 shadow-inner">
2093
+ <button type="button" onclick="setQuizLang('th')" id="lang-btn-th" class="px-2 sm:px-2.5 py-1 rounded-lg text-xs font-bold transition-all bg-rose-600 text-white shadow-xs">🇹🇭 TH</button>
2094
+ <button type="button" onclick="setQuizLang('en')" id="lang-btn-en" class="px-2 sm:px-2.5 py-1 rounded-lg text-xs font-bold transition-all text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white">🇬🇧 EN</button>
2095
+ <button type="button" onclick="setQuizLang('zh')" id="lang-btn-zh" class="px-2 sm:px-2.5 py-1 rounded-lg text-xs font-bold transition-all text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white">🇨🇳 中文</button>
2096
+ </div>
2097
+
2098
+ <button type="button" onclick="toggleTheme()" class="w-8 h-8 rounded-full bg-stone-100 hover:bg-rose-100 dark:bg-stone-900 dark:hover:bg-rose-950 border border-stone-200 dark:border-stone-700 text-stone-700 dark:text-rose-200 flex items-center justify-center transition-colors shrink-0 cursor-pointer shadow-2xs" title="Toggle Theme">
2099
+ <svg id="modal-theme-icon-sun" class="w-4 h-4 text-amber-500 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2100
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path>
2101
+ </svg>
2102
+ <svg id="modal-theme-icon-moon" class="w-4 h-4 text-rose-600 block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2103
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
2104
+ </svg>
2105
+ </button>
2106
+
2107
+ <button type="button" onclick="closeQuizModal()" class="w-8 h-8 rounded-full bg-stone-100 hover:bg-rose-100 dark:bg-stone-900 dark:hover:bg-rose-950 border border-stone-200 dark:border-stone-700 text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-white flex items-center justify-center transition-colors shrink-0 cursor-pointer" aria-label="Close Quiz">
2108
+ ✕
2109
+ </button>
2110
+ </div>
2111
+ </div>
2112
+
2113
+ <div class="w-full bg-stone-200 dark:bg-stone-900 h-1.5 overflow-hidden">
2114
+ <div id="quiz-progress-bar" class="bg-gradient-to-r from-rose-500 via-pink-500 to-amber-400 h-full transition-all duration-300" style="width: 6.66%;"></div>
2115
+ </div>
2116
+
2117
+ <div class="p-5 sm:p-7 overflow-y-auto space-y-6 flex-1" id="quiz-content">
2118
+ </div>
2119
+ </div>
2120
+ </div>
2121
+
2122
+ <div id="toast-container" class="fixed bottom-5 right-5 z-50 flex flex-col gap-3 pointer-events-none max-w-sm sm:max-w-md w-full px-4"></div>
2123
+
2124
+ <script>
2125
+ function toggleTheme() {
2126
+ const isDark = document.documentElement.classList.toggle('dark');
2127
+ localStorage.setItem('kiku_theme', isDark ? 'dark' : 'light');
2128
+ localStorage.setItem('theme', isDark ? 'dark' : 'light');
2129
+ updateThemeIcons();
2130
+ }
2131
+
2132
+ function updateThemeIcons() {
2133
+ const isDark = document.documentElement.classList.contains('dark');
2134
+ const sun = document.getElementById('theme-icon-sun');
2135
+ const moon = document.getElementById('theme-icon-moon');
2136
+ const modalSun = document.getElementById('modal-theme-icon-sun');
2137
+ const modalMoon = document.getElementById('modal-theme-icon-moon');
2138
+ const label = document.getElementById('theme-label');
2139
+ if (sun && moon) {
2140
+ if (isDark) {
2141
+ sun.classList.remove('hidden');
2142
+ moon.classList.add('hidden');
2143
+ if (label) label.innerText = 'Dark';
2144
+ } else {
2145
+ sun.classList.add('hidden');
2146
+ moon.classList.remove('hidden');
2147
+ if (label) label.innerText = 'Light';
2148
+ }
2149
+ }
2150
+ if (modalSun && modalMoon) {
2151
+ if (isDark) {
2152
+ modalSun.classList.remove('hidden');
2153
+ modalMoon.classList.add('hidden');
2154
+ } else {
2155
+ modalSun.classList.add('hidden');
2156
+ modalMoon.classList.remove('hidden');
2157
+ }
2158
+ }
2159
+ }
2160
+
2161
+ let allExpanded = false;
2162
+ let currentVipKey = '';
2163
+ try { localStorage.removeItem('kiku_vip_key'); } catch {}
2164
+ let quizLang = 'th';
2165
+
2166
+ const quizI18n = {
2167
+ th: {
2168
+ title: "Ju Jingyi Miju Trivia Challenge 🌸",
2169
+ subtitle: "ตอบคำถามแฟนพันธุ์แท้จวีจิ้งอี 15 ข้อเพื่อรับ Developer VIP Key (1,000 req/min)",
2170
+ questionOf: (idx, total) => 'คำถามที่ ' + idx + ' จาก ' + total,
2171
+ score: (score, total) => 'คะแนน: ' + score + ' / ' + total,
2172
+ correct: "🌸 ถูกต้องสมบูรณ์!",
2173
+ incorrect: "✗ ยังไม่ถูกต้องนะมี่จวี!",
2174
+ tryAgain: "ลองตอบใหม่อีกครั้ง ↺",
2175
+ generatingKey: "กำลังสร้าง Developer VIP Key ประจำตัวของคุณ...",
2176
+ generatingDesc: "Verifying 15/15 score and issuing VIP credentials...",
2177
+ congratsTitle: "ยินดีด้วย! คุณคือแฟนพันธุ์แท้จวีจิ้งอี 🌸",
2178
+ congratsDesc: "ตอบถูกต้องครบ 15/15 ข้อ! ปลดล็อกโควต้า Developer VIP Tier 1,000 requests/min สำเร็จ",
2179
+ keyLabel: "Developer VIP API Key",
2180
+ copyKey: "Copy Key",
2181
+ applyBtn: "⚡ ใช้งาน VIP Key กับ Interactive Console ทันที",
2182
+ finishBtn: "เสร็จสิ้น",
2183
+ errorTitle: "เกิดข้อผิดพลาดในการออก Key",
2184
+ retryQuiz: "ลองใหม่อีกครั้ง",
2185
+ copyAlert: (key) => '📋 คัดลอก Developer VIP Key เรียบร้อยแล้ว!\\n\\n' + key,
2186
+ appliedAlert: "👑 นำ Developer VIP Key ไปใช้งานกับ Interactive Test Console เรียบร้อยแล้ว! (โควต้า 1,000 req/min)"
2187
+ },
2188
+ en: {
2189
+ title: "Ju Jingyi Miju Trivia Challenge 🌸",
2190
+ subtitle: "Answer all 15 questions correctly to claim your Developer VIP Key (1,000 req/min)",
2191
+ questionOf: (idx, total) => 'Question ' + idx + ' of ' + total,
2192
+ score: (score, total) => 'Score: ' + score + ' / ' + total,
2193
+ correct: "🌸 Perfectly Correct!",
2194
+ incorrect: "✗ Not quite right, Miju!",
2195
+ tryAgain: "Try Again ↺",
2196
+ generatingKey: "Generating your Developer VIP Key...",
2197
+ generatingDesc: "Verifying 15/15 score and issuing VIP credentials...",
2198
+ congratsTitle: "Congratulations! True Ju Jingyi Fan 🌸",
2199
+ congratsDesc: "15/15 Perfect Score! You have unlocked Developer VIP Tier (1,000 req/min).",
2200
+ keyLabel: "Developer VIP API Key",
2201
+ copyKey: "Copy Key",
2202
+ applyBtn: "⚡ Apply VIP Key to Interactive Console Now",
2203
+ finishBtn: "Done",
2204
+ errorTitle: "Key Issuing Failed",
2205
+ retryQuiz: "Try Again",
2206
+ copyAlert: (key) => '📋 Copied Developer VIP Key to clipboard!\\n\\n' + key,
2207
+ appliedAlert: "👑 Developer VIP Key has been applied to the live test console! (1,000 req/min quota active)"
2208
+ },
2209
+ zh: {
2210
+ title: "鞠婧祎蜜橘专属问答挑战 🌸",
2211
+ subtitle: "正确回答全部15道题目即可获取开发者 VIP Key (1,000次/分钟)",
2212
+ questionOf: (idx, total) => '第 ' + idx + ' 题 / 共 ' + total + ' 题',
2213
+ score: (score, total) => '得分: ' + score + ' / ' + total,
2214
+ correct: "🌸 回答完全正确!",
2215
+ incorrect: "✗ 答错啦,蜜橘继续加油!",
2216
+ tryAgain: "重新作答 ↺",
2217
+ generatingKey: "正在为您生成专属开发者 VIP Key...",
2218
+ generatingDesc: "系统正在验证满分答卷并颁发专属权限...",
2219
+ congratsTitle: "恭喜通关!鞠婧祎资深铁杆蜜橘 🌸",
2220
+ congratsDesc: "答对全部15道题目!成功解锁 Developer VIP Tier 1,000次/分钟高频配额。",
2221
+ keyLabel: "开发者 VIP API Key",
2222
+ copyKey: "复制密钥",
2223
+ applyBtn: "⚡ 立即将 VIP Key 应用于测试控制台",
2224
+ finishBtn: "完成",
2225
+ errorTitle: "密钥生成失败",
2226
+ retryQuiz: "重新尝试",
2227
+ copyAlert: (key) => '📋 开发者 VIP Key 已复制到剪贴板!\\n\\n' + key,
2228
+ appliedAlert: "👑 已成功将 VIP Key 应用于在线接口控制台!(已激活 1,000次/分钟高频配额)"
2229
+ }
2230
+ };
2231
+
2232
+ const quizQuestions = [
2233
+ {
2234
+ q: {
2235
+ th: "1. วันเกิดของจวีจิ้งอี (Ju Jingyi) คือวันใด?",
2236
+ en: "1. What is Ju Jingyi's date of birth?",
2237
+ zh: "1. 鞠婧祎的出生日期是哪一天?"
2238
+ },
2239
+ options: {
2240
+ th: ["18 มิถุนายน 1994", "23 กรกฎาคม 1995", "4 มีนาคม 1993", "12 กันยายน 1996"],
2241
+ en: ["June 18, 1994", "July 23, 1995", "March 4, 1993", "September 12, 1996"],
2242
+ zh: ["1994年6月18日", "1995年7月23日", "1993年3月4日", "1996年9月12日"]
2243
+ },
2244
+ hint: {
2245
+ th: "เธอเกิดในช่วงกลางปี ค.ศ. 1994 ในจีน ลองนึกถึงเดือนที่อยู่กึ่งกลางของปี",
2246
+ en: "She was born in mid-1994 in China — think of the month that sits near the midpoint of the calendar year.",
2247
+ zh: "她出生于1994年,月份大约在全年中段,仔细想想是哪一天。"
2248
+ }
2249
+ },
2250
+ {
2251
+ q: {
2252
+ th: "2. จวีจิ้งอีเกิดที่เมืองและมณฑลใดในประเทศจีน?",
2253
+ en: "2. In which city and province of China was Ju Jingyi born?",
2254
+ zh: "2. 鞠婧祎出生于中国的哪座城市?"
2255
+ },
2256
+ options: {
2257
+ th: ["เมืองสุยหนิง มณฑลเสฉวน (Suining, Sichuan)", "เมืองเฉิงตู มณฑลเสฉวน", "เมืองหางโจว มณฑลเจ้อเจียง", "เทศบาลนครเซี่ยงไฮ้"],
2258
+ en: ["Suining, Sichuan Province", "Chengdu, Sichuan Province", "Hangzhou, Zhejiang Province", "Shanghai Municipality"],
2259
+ zh: ["四川省遂宁市", "四川省成都市", "浙江省杭州市", "上海市"]
2260
+ },
2261
+ hint: {
2262
+ th: "บ้านเกิดของเธออยู่ในมณฑลที่ขึ้นชื่อด้านอาหารรสเผ็ด หมีแพนด้า และเมืองหลวงของมณฑลคือเฉิงตู แต่เธอเกิดในเมืองที่เล็กกว่า",
2263
+ en: "Her hometown province is known for spicy food and giant pandas — its capital is Chengdu, but she was born in a smaller city there.",
2264
+ zh: "她家乡所在省份以麻辣美食和大熊猫闻名,省会是成都,但她出生在省内另一座较小的城市。"
2265
+ }
2266
+ },
2267
+ {
2268
+ q: {
2269
+ th: "3. ชื่อแฟนคลับอย่างเป็นทางการ (Official Fandom Name) ของจวีจิ้งอีคืออะไร?",
2270
+ en: "3. What is the official fandom name of Ju Jingyi?",
2271
+ zh: "3. 鞠婧祎的官方粉丝名是什么?"
2272
+ },
2273
+ options: {
2274
+ th: ["มี่จวี (Miju / 蜜橘 - ส้มหวาน)", "เสี่ยวเหมย (Xiaomei)", "ชิงเฉิง (Qingcheng)", "ไป๋เหอ (Baihe)"],
2275
+ en: ["Miju (蜜橘 - Sweet Tangerine)", "Xiaomei", "Qingcheng", "Baihe"],
2276
+ zh: ["蜜橘 (Miju)", "小妹 (Xiaomei)", "倾城 (Qingcheng)", "百合 (Baihe)"]
2277
+ },
2278
+ hint: {
2279
+ th: "ชื่อแฟนด้อมเป็นชื่อผลไม้สีส้มขนาดเล็ก รสหวานเปรี้ยว นิยมรับประทานในเทศกาลฤดูหนาว",
2280
+ en: "The fandom name is a small sweet citrus fruit with orange skin, often enjoyed during winter festivals in East Asia.",
2281
+ zh: "粉丝名是一种小巧甘甜的橙色柑橘类水果,东亚冬季节日中常见。"
2282
+ }
2283
+ },
2284
+ {
2285
+ q: {
2286
+ th: "4. สีประจำตัวและสีทางการของแฟนคลับ (Official Support Colors) มีสีอะไรบ้าง?",
2287
+ en: "4. What are the official fan support colors of Ju Jingyi?",
2288
+ zh: "4. 鞠婧祎与蜜橘的官方应援色是哪些?"
2289
+ },
2290
+ options: {
2291
+ th: ["น้ำเงินฟ้าคราม (Sky Blue), ชมพูดอกซากุระ (Sakura Pink), และทองประกาย (Gold)", "แดงทับทิม, เขียวมรกต, ม่วงลาเวนเดอร์", "เหลืองมะนาว, ส้มพีช, ขาวไข่มุก", "ดำสนิท, เงินเมทัลลิก, ฟ้าพาสเทล"],
2292
+ en: ["Sky Blue (蔚蓝色), Sakura Pink (樱花粉), and Shining Gold (耀金)", "Ruby Red, Emerald Green, Lavender Purple", "Lemon Yellow, Peach Orange, Pearl White", "Jet Black, Metallic Silver, Pastel Blue"],
2293
+ zh: ["蔚蓝色、樱花粉、耀金", "红宝石色、翡翠绿、薰衣草紫", "柠檬黄、蜜桃橙、珍珠白", "曜石黑、金属银、浅天蓝"]
2294
+ },
2295
+ hint: {
2296
+ th: "สีทั้งสามนำแรงบันดาลใจมาจากท้องฟ้า ดอกไม้ยามใบไม้ผลิ และแสงสว่างของดวงอาทิตย์",
2297
+ en: "The three colors draw inspiration from the sky, spring blossoms, and the radiance of sunlight.",
2298
+ zh: "三种颜色分别灵感来自晴朗的天空、春日的花朵以及阳光般的光辉。"
2299
+ }
2300
+ },
2301
+ {
2302
+ q: {
2303
+ th: "5. จวีจิ้งอีสร้างประวัติศาสตร์ในวง SNH48 อย่างไร?",
2304
+ en: "5. What historic milestone did Ju Jingyi achieve during her SNH48 career?",
2305
+ zh: "5. 鞠婧祎在SNH48总选举中创造了怎样的历史纪录?"
2306
+ },
2307
+ options: {
2308
+ th: ["ชนะการเลือกตั้งอันดับ 1 สองปีซ้อน (2016 & 2017) และเข้าสู่ Star Hall of Fame", "เป็นกัปตันทีมคนแรกของวง SNH48", "มีผลงานเพลงมากที่สุดในวงถึง 100 เพลง", "ก่อตั้งวงสาขาย่อยในปักกิ่ง"],
2309
+ en: ["Won 1st Place in General Elections twice in a row (2016 & 2017) and promoted to Star Hall of Fame", "Was the first overall team captain of SNH48", "Released the highest number of singles in group history (100 songs)", "Founded the Beijing sub-unit"],
2310
+ zh: ["蝉联第三届与第四届总选举冠军并晋升SNH48明星殿堂", "成为SNH48全团第一任总队长", "在团期间发行了100首单曲", "创立了北京姐妹团"]
2311
+ },
2312
+ hint: {
2313
+ th: "เธอเป็นสมาชิกคนเดียวที่ครองอันดับหนึ่งต่อเนื่องสองสมัย จนได้รับสิทธิ์พิเศษสูงสุดในวง",
2314
+ en: "She is the only member to top the annual election rankings in two consecutive years, earning the highest honor tier.",
2315
+ zh: "她是唯一连续两届年度总选登顶的成员,因此获得了团队最高荣誉资格。"
2316
+ }
2317
+ },
2318
+ {
2319
+ q: {
2320
+ th: "6. ผลงานซีรีส์แนวย้อนยุคที่สร้างชื่อเสียงและเป็นผลงานแจ้งเกิดระดับตำนานของจวีจิ้งอีคือเรื่องใด?",
2321
+ en: "6. Which classic historical costume drama served as Ju Jingyi's breakthrough breakout role?",
2322
+ zh: "6. 哪部经典古装剧是鞠婧祎经典代表作并让她一举成名?"
2323
+ },
2324
+ options: {
2325
+ th: ["ตำนานหยุนซี มเหสียอดอัจฉริยะแห่งพิษ (Legend of Yunxi - 芸汐传)", "กรุ่นรักกลิ่นบุปผา (The Blooms at Ruyi Pavilion)", "ปรมาจารย์ลัทธิมาร (The Untamed)", "สามชาติสามภพ ป่าท้อสิบหลี่ (Eternal Love)"],
2326
+ en: ["Legend of Yunxi (芸汐传 - 2018)", "The Blooms at Ruyi Pavilion (如意芳霏)", "The Untamed (陈情令)", "Eternal Love (三生三世十里桃花)"],
2327
+ zh: ["《芸汐传》(Legend of Yunxi)", "《如意芳霏》(The Blooms at Ruyi Pavilion)", "《陈情令》(The Untamed)", "《三生三世十里桃花》(Eternal Love)"]
2328
+ },
2329
+ hint: {
2330
+ th: "ซีรีส์แจ้งเกิดปี 2018 เล่าเรื่องหญิงสาวอัจฉริยะด้านพิษในราชสำนัก ชื่อเรื่องตั้งตามชื่อตัวละครเอก",
2331
+ en: "This 2018 drama features a brilliant young woman skilled in poisons at an imperial court — the title is named after her character.",
2332
+ zh: "这部2018年古装剧讲述一位精通毒术的聪慧女子,剧名取自女主角的名字。"
2333
+ }
2334
+ },
2335
+ {
2336
+ q: {
2337
+ th: "7. ในซีรีส์ยอดฮิต 'บุปผารักอลวน (In Blossom - 2024)' จวีจิ้งอีรับบทเป็นตัวละครใด?",
2338
+ en: "7. In the 2024 hit mystery drama 'In Blossom', which character(s) did Ju Jingyi portray?",
2339
+ zh: "7. 在2024年爆款古装悬疑剧《花间令》中,鞠婧祎饰演的角色是?"
2340
+ },
2341
+ options: {
2342
+ th: ["หยางไฉ่เวย / ซ่างกวนจื่อ (Yang Caiwei / Shangguan Zhi)", "หานอวิ๋นซี / ซื่อเฉียง", "ไป๋ซู่เจิน / เสี่ยวชิง", "ฟู่หรง / ชุยหว่าน"],
2343
+ en: ["Yang Caiwei / Shangguan Zhi (Dual Personas)", "Han Yunxi / Si Qiang", "Bai Suzhen / Xiaoqing", "Fu Rong / Cui Wan"],
2344
+ zh: ["杨采薇 / 上官芷 (一人分饰两角)", "韩芸汐 / 四娘", "白素贞 / 小青", "傅容 / 崔绾"]
2345
+ },
2346
+ hint: {
2347
+ th: "เธอรับบท 2 ตัวละครในเรื่องเดียว นิสัยตรงข้ามกันสิ้นเชิง — หนึ่งใจดี อีกหนึ่งเจ้าเล่ห์",
2348
+ en: "She played two characters in the same drama with opposite personalities — one gentle, one scheming.",
2349
+ zh: "她在剧中同时饰演两个性格截然相反的角色,一善一奸。"
2350
+ }
2351
+ },
2352
+ {
2353
+ q: {
2354
+ th: "8. เครื่องดนตรีที่จวีจิ้งอีมีความเชี่ยวชาญระดับสูงและฝึกฝนมาตั้งแต่เด็กคือเครื่องดนตรีใด?",
2355
+ en: "8. Which musical instruments is Ju Jingyi highly proficient in and has played since childhood?",
2356
+ zh: "8. 鞠婧祎从小精通并多次在舞台上惊艳演奏的乐器是?"
2357
+ },
2358
+ options: {
2359
+ th: ["ไวโอลิน (Violin) และ กู่เจิง (Guzheng)", "กลองชุด และ เบส", "ทรัมเป็ต และ แซกโซโฟน", "ฮาร์ป และ ฟลุต"],
2360
+ en: ["Violin and Guzheng (Chinese Zither)", "Drum Set and Electric Bass", "Trumpet and Saxophone", "Harp and Flute"],
2361
+ zh: ["小提琴与古筝", "架子鼓与贝斯", "小号与萨克斯", "竖琴与长笛"]
2362
+ },
2363
+ hint: {
2364
+ th: "เธอเล่นเครื่องดนตรีสายชนิดหนึ่งจากตะวันตก และอีกชนิดจากเอเชียตะวันออก ทั้งคู่ใช้สายเป็นแหล่งเสียง",
2365
+ en: "She plays one Western string instrument and one traditional East Asian string instrument.",
2366
+ zh: "她精通一件西方弦乐器和一件东亚传统弦乐器,均以弦为发声方式。"
2367
+ }
2368
+ },
2369
+ {
2370
+ q: {
2371
+ th: "9. แบรนด์แฟชั่นระดับไฮเอนด์ระดับโลกแบรนด์ใดที่แต่งตั้งจวีจิ้งอีเป็น Global Brand Ambassador?",
2372
+ en: "9. Which world-renowned international luxury fashion house appointed Ju Jingyi as its Global Brand Ambassador?",
2373
+ zh: "9. 哪家世界顶级奢华时尚品牌官宣鞠婧祎为全球品牌代言人?"
2374
+ },
2375
+ options: {
2376
+ th: ["Versace (เวอร์ซาเช่)", "Gucci (กุชชี)", "Chanel (ชาเนล)", "Dior (ดิออร์)"],
2377
+ en: ["Versace", "Gucci", "Chanel", "Dior"],
2378
+ zh: ["范思哲 (Versace)", "古驰 (Gucci)", "香奈儿 (Chanel)", "迪奥 (Dior)"]
2379
+ },
2380
+ hint: {
2381
+ th: "แบรนด์นี้มาจากอิตาลี ใช้หัวเมดูซ่าเป็นสัญลักษณ์ และมีชื่อเสียงด้านแฟชั่นสุดหรู",
2382
+ en: "This Italian luxury brand is famous for its Medusa head emblem and bold, opulent designs.",
2383
+ zh: "这个来自意大利的奢侈品牌以神话中的美杜莎头像为标志,以大胆华丽的设计著称。"
2384
+ }
2385
+ },
2386
+ {
2387
+ q: {
2388
+ th: "10. ซิงเกิลเดี่ยวเพลงหลักล่าสุดประจำปี 2026 ของจวีจิ้งอีมีชื่อว่าอะไร?",
2389
+ en: "10. What is the title of Ju Jingyi's latest 2026 flagship solo single?",
2390
+ zh: "10. 鞠婧祎2026年最新发布的个人主打单曲名称是?"
2391
+ },
2392
+ options: {
2393
+ th: ["FIND THE WAY", "Rain Love (雨爱)", "Everyday (日日夜夜)", "Don't Touch"],
2394
+ en: ["FIND THE WAY", "Rain Love (雨爱)", "Everyday", "Don't Touch"],
2395
+ zh: ["《FIND THE WAY》", "《雨爱》(Rain Love)", "《日日夜夜》(Everyday)", "《Don't Touch》"]
2396
+ },
2397
+ hint: {
2398
+ th: "ชื่อเพลงปี 2026 เป็นภาษาอังกฤษ 3 คำ สื่อถึงการค้นหาทิศทางในชีวิต",
2399
+ en: "The 2026 single has a 3-word English title about searching for direction in life.",
2400
+ zh: "这首2026年单曲的名称由三个英文单词组成,传达寻找人生方向的主题。"
2401
+ }
2402
+ },
2403
+ {
2404
+ q: {
2405
+ th: "11. ชื่อเล่นและฉายาที่แฟนๆ และคนทั่วไปเรียกจวีจิ้งอีด้วยความรักคืออะไร?",
2406
+ en: "11. What is the most affectionate nickname by which fans and the public refer to Ju Jingyi?",
2407
+ zh: "11. 粉丝与大众最常亲切称呼鞠婧祎的昵称是?"
2408
+ },
2409
+ options: {
2410
+ th: ["เสี่ยวจวี (Xiao Ju / 小鞠) หรือ Kiku", "จวีเอ๋อร์ (Ju'er)", "อาอี (A-Yi)", "จิ้งจิ้ง (JingJing)"],
2411
+ en: ["Xiao Ju (小鞠) / Kiku", "Ju'er", "A-Yi", "JingJing"],
2412
+ zh: ["小鞠 (Xiao Ju) / Kiku", "鞠儿", "阿祎", "婧婧"]
2413
+ },
2414
+ hint: {
2415
+ th: "ชื่อเล่นนี้ดัดแปลงมาจากนามสกุลจีนของเธอ และออกเสียงคล้ายกับชื่อในภาษาญี่ปุ่น",
2416
+ en: "The nickname is derived from her Chinese surname and sounds similar to a Japanese given name.",
2417
+ zh: "这个昵称源自她的中文姓氏,发音也与一个常见的日文名字相近。"
2418
+ }
2419
+ },
2420
+ {
2421
+ q: {
2422
+ th: "12. เพลงประกอบซีรีส์ 'บุปผารักอลวน (In Blossom OST)' ที่จวีจิ้งอีขับร้องมีชื่อว่าอะไร?",
2423
+ en: "12. What is the title of the original soundtrack (OST) song performed by Ju Jingyi for 'In Blossom'?",
2424
+ zh: "12. 鞠婧祎为电视剧《花间令》献唱的主题曲/插曲是?"
2425
+ },
2426
+ options: {
2427
+ th: ["ดั่งแพรไหม (如纱 - Ru Sha / Like a Silk)", "รักแรกพบ (First Love)", "แสงสว่างในสายลม", "บุปผาร่วงโรย"],
2428
+ en: ["Like a Silk (如纱 - Ru Sha)", "First Love", "Light in the Wind", "Fallen Blossoms"],
2429
+ zh: ["《如纱》(Ru Sha / Like a Silk)", "《初恋》", "《风中之光》", "《落花》"]
2430
+ },
2431
+ hint: {
2432
+ th: "ชื่อเพลงเป็นอักษรจีน 2 ตัว สื่อถึงสิ่งบางเบาโปร่งแสง เหมือนผ้าที่ปลิวตามสายลม",
2433
+ en: "The song title is two Chinese characters evoking something light and translucent, like fabric in the breeze.",
2434
+ zh: "歌名由两个汉字组成,意境轻盈透明,如随风飘动的薄纱。"
2435
+ }
2436
+ },
2437
+ {
2438
+ q: {
2439
+ th: "13. สัตว์เลี้ยงตัวโปรดของจวีจิ้งอีคือสัตว์ชนิดใดและมีชื่อว่าอะไร?",
2440
+ en: "13. What pet does Ju Jingyi cherish, and what is its name?",
2441
+ zh: "13. 鞠婧祎心爱的宠物猫咪名字叫什么?"
2442
+ },
2443
+ options: {
2444
+ th: ["แมว ชื่อว่า 'ฮาฮา' (Haha / 哈哈)", "สุนัข ชื่อว่า 'พุดดิ้ง'", "กระต่าย ชื่อว่า 'เสี่ยวไป๋'", "นกแก้ว ชื่อว่า 'กีกี้'"],
2445
+ en: ["A cat named 'Haha' (哈哈)", "A puppy named 'Pudding'", "A rabbit named 'Xiaobai'", "A parrot named 'Kiki'"],
2446
+ zh: ["猫咪,名字叫“哈哈”", "小狗,名字叫“布丁”", "小兔,名字叫“小白”", "鹦鹉,名字叫“琪琪”"]
2447
+ },
2448
+ hint: {
2449
+ th: "สัตว์เลี้ยงของเธอเป็นแมว และชื่อของมันฟังดูเหมือนเสียงหัวเราะในหลายภาษา",
2450
+ en: "Her pet is a cat whose name sounds like the onomatopoeia for laughter in many languages.",
2451
+ zh: "她养的是一只猫咪,它的名字读起来像是笑声的拟声词。"
2452
+ }
2453
+ },
2454
+ {
2455
+ q: {
2456
+ th: "14. ในซีรีส์ 'ตำนานนางพญางูขาว (The Legend of White Snake - 2019)' จวีจิ้งอีรับบทเป็นใคร?",
2457
+ en: "14. In the 2019 TV series 'The Legend of White Snake', which iconic role did Ju Jingyi star as?",
2458
+ zh: "14. 在2019版电视剧《新白娘子传奇》中,鞠婧祎领衔主演的角色是?"
2459
+ },
2460
+ options: {
2461
+ th: ["ไป๋ซู่เจิน (Bai Suzhen)", "เสี่ยวชิง (Xiaoqing)", "หลินไต้อวี้ (Lin Daiyu)", "ซูเสี่ยวเม่ย"],
2462
+ en: ["Bai Suzhen (Lady White Snake)", "Xiaoqing (Green Snake)", "Lin Daiyu", "Su Xiaomei"],
2463
+ zh: ["白素贞 (Bai Suzhen)", "小青 (Xiaoqing)", "林黛玉", "苏小妹"]
2464
+ },
2465
+ hint: {
2466
+ th: "ตัวละครที่เธอรับบทเป็นตัวเอกหญิงจากตำนานจีนคลาสสิก ชื่อตัวละครอ้างอิงถึงงูสีขาว",
2467
+ en: "The character she portrayed is the female lead of a classic Chinese legend — her name references a white serpent.",
2468
+ zh: "她饰演的是中国经典民间传说的女主角,角色名与一条白蛇有关。"
2469
+ }
2470
+ },
2471
+ {
2472
+ q: {
2473
+ th: "15. คติประจำใจและปรัชญาการทำงานที่มีชื่อเสียงของจวีจิ้งอีคือข้อใด?",
2474
+ en: "15. What is Ju Jingyi's renowned life philosophy and guiding motto?",
2475
+ zh: "15. 鞠婧祎广为人知的处世金句与座右铭是哪一句?"
2476
+ },
2477
+ options: {
2478
+ th: ["认真工作,踏实生活 (ตั้งใจทำงาน ใช้ชีวิตอย่างมั่นคงและก้าวไปข้างหน้าอย่างจริงใจ)", "อย่าหยุดเมื่อเหนื่อย แต่หยุดเมื่อชนะ", "ความสำเร็จอยู่ที่การรอคอย", "ความสมบูรณ์แบบไม่มีอยู่จริง"],
2479
+ en: ["Work hard with dedication, live life grounded and sincerely (认真工作,踏实生活)", "Don't stop when tired, stop when victorious", "Success comes to those who wait", "Perfection does not exist"],
2480
+ zh: ["“认真工作,踏实生活”", "“不要在累的时候停下,要在成功时停下”", "“成功在于等待”", "“完美并不存在”"]
2481
+ },
2482
+ hint: {
2483
+ th: "คติของเธอเป็นประโยคจีน 8 ตัวอักษร แบ่งเป็นสองส่วน พูดถึงการทำงานและการใช้ชีวิตอย่างจริงใจ",
2484
+ en: "Her motto is an eight-character Chinese phrase, split into two parts — one about work, one about living sincerely.",
2485
+ zh: "她的座右铭是一句八字短语,分两句,分别谈及工作态度与生活方式。"
2486
+ }
2487
+ }
2488
+ ];
2489
+
2490
+ let quizIndex = 0;
2491
+ let quizScore = 0;
2492
+ let quizAnswered = false;
2493
+ let activeQuizQuestions = [];
2494
+
2495
+ function shuffleQuizQuestions() {
2496
+ const indexed = quizQuestions.map((q, idx) => ({ ...q, originalIndex: idx }));
2497
+ for (let i = indexed.length - 1; i > 0; i--) {
2498
+ const j = Math.floor(Math.random() * (i + 1));
2499
+ const temp = indexed[i];
2500
+ indexed[i] = indexed[j];
2501
+ indexed[j] = temp;
2502
+ }
2503
+
2504
+ activeQuizQuestions = indexed.map(q => {
2505
+ const order = [0, 1, 2, 3];
2506
+ for (let i = order.length - 1; i > 0; i--) {
2507
+ const j = Math.floor(Math.random() * (i + 1));
2508
+ const temp = order[i];
2509
+ order[i] = order[j];
2510
+ order[j] = temp;
2511
+ }
2512
+
2513
+ const shuffledOptions = {
2514
+ th: order.map(idx => q.options.th[idx]),
2515
+ en: order.map(idx => q.options.en[idx]),
2516
+ zh: order.map(idx => q.options.zh[idx])
2517
+ };
2518
+
2519
+ return {
2520
+ originalIndex: q.originalIndex,
2521
+ q: q.q,
2522
+ hint: q.hint,
2523
+ options: shuffledOptions,
2524
+ optionOrder: order
2525
+ };
2526
+ });
2527
+ }
2528
+
2529
+ function setQuizLang(lang) {
2530
+ if (!['th', 'en', 'zh'].includes(lang)) return;
2531
+ quizLang = lang;
2532
+
2533
+ ['th', 'en', 'zh'].forEach(l => {
2534
+ const btn = document.getElementById('lang-btn-' + l);
2535
+ if (btn) {
2536
+ if (l === lang) {
2537
+ btn.className = 'px-2 sm:px-2.5 py-1 rounded-lg text-xs font-bold transition-all bg-rose-600 text-white shadow-xs';
2538
+ } else {
2539
+ btn.className = 'px-2 sm:px-2.5 py-1 rounded-lg text-xs font-bold transition-all text-stone-400 hover:text-white';
2540
+ }
2541
+ }
2542
+ });
2543
+
2544
+ const modalTitle = document.getElementById('quiz-modal-title');
2545
+ const modalSubtitle = document.getElementById('quiz-modal-subtitle');
2546
+ if (modalTitle) modalTitle.innerText = quizI18n[quizLang].title;
2547
+ if (modalSubtitle) modalSubtitle.innerText = quizI18n[quizLang].subtitle;
2548
+
2549
+ const total = activeQuizQuestions.length > 0 ? activeQuizQuestions.length : quizQuestions.length;
2550
+ if (quizIndex < total) {
2551
+ renderQuizQuestion();
2552
+ } else {
2553
+ renderQuizVictory();
2554
+ }
2555
+ }
2556
+
2557
+ let userQuizAnswers = [];
2558
+ let currentQuizChallengeToken = null;
2559
+
2560
+ async function openQuizModal() {
2561
+ quizIndex = 0;
2562
+ quizScore = 0;
2563
+ quizAnswered = false;
2564
+ userQuizAnswers = [];
2565
+ currentQuizChallengeToken = null;
2566
+ shuffleQuizQuestions();
2567
+ const modal = document.getElementById('quiz-modal');
2568
+ if (modal) {
2569
+ modal.classList.remove('hidden');
2570
+ modal.classList.add('flex');
2571
+ }
2572
+ setQuizLang(quizLang);
2573
+
2574
+ try {
2575
+ const res = await fetch('/api/v1/quiz/challenge');
2576
+ if (res.ok) {
2577
+ const json = await res.json();
2578
+ if (json.data?.challengeToken) {
2579
+ currentQuizChallengeToken = json.data.challengeToken;
2580
+ }
2581
+ }
2582
+ } catch {}
2583
+ }
2584
+
2585
+ function closeQuizModal() {
2586
+ const modal = document.getElementById('quiz-modal');
2587
+ if (modal) {
2588
+ modal.classList.add('hidden');
2589
+ modal.classList.remove('flex');
2590
+ }
2591
+ }
2592
+
2593
+ function renderQuizQuestion() {
2594
+ const content = document.getElementById('quiz-content');
2595
+ const progressBar = document.getElementById('quiz-progress-bar');
2596
+ if (!content) return;
2597
+
2598
+ quizAnswered = false;
2599
+ const questionsList = activeQuizQuestions.length > 0 ? activeQuizQuestions : quizQuestions;
2600
+ const total = questionsList.length;
2601
+ const progressPercent = Math.min(100, Math.round(((quizIndex + 1) / total) * 100));
2602
+ if (progressBar) progressBar.style.width = progressPercent + '%';
2603
+
2604
+ if (quizIndex >= total) {
2605
+ renderQuizVictory();
2606
+ return;
2607
+ }
2608
+
2609
+ const q = questionsList[quizIndex];
2610
+ const i18n = quizI18n[quizLang];
2611
+ const rawQuestionText = q.q[quizLang] || q.q.en;
2612
+ const questionText = typeof rawQuestionText === 'string' ? rawQuestionText.replace(/^\s*\d+[.)]\s*/, '') : rawQuestionText;
2613
+ const optionsList = q.options[quizLang] || q.options.en;
2614
+
2615
+ let html = \`
2616
+ <div class="space-y-5">
2617
+ <div class="flex items-center justify-between">
2618
+ <span class="text-xs font-bold uppercase tracking-wider text-rose-700 dark:text-rose-400">
2619
+ \${i18n.questionOf(quizIndex + 1, total)}
2620
+ </span>
2621
+ <span class="text-xs font-bold px-2.5 py-0.5 rounded-full bg-amber-100 dark:bg-rose-950 border border-amber-300 dark:border-rose-800/60 text-amber-900 dark:text-amber-300">
2622
+ \${i18n.score(quizScore, total)}
2623
+ </span>
2624
+ </div>
2625
+
2626
+ <div>
2627
+ <h4 class="text-base sm:text-lg font-bold text-stone-900 dark:text-white leading-relaxed">\${questionText}</h4>
2628
+ </div>
2629
+
2630
+ <div class="space-y-2.5">
2631
+ \`;
2632
+
2633
+ optionsList.forEach((opt, idx) => {
2634
+ const letters = ['A', 'B', 'C', 'D'];
2635
+ html += \`
2636
+ <button type="button" id="opt-btn-\${idx}" onclick="handleSelectOption(\${idx})" class="w-full text-left p-3.5 sm:p-4 rounded-2xl bg-stone-100 hover:bg-rose-50/70 dark:bg-stone-900 dark:hover:bg-stone-800/80 border border-stone-200 hover:border-rose-400 dark:border-stone-800 dark:hover:border-rose-600/80 transition-all flex items-center justify-between gap-3 group cursor-pointer active:scale-99 shadow-2xs">
2637
+ <div class="flex items-center gap-3">
2638
+ <span class="w-7 h-7 rounded-xl bg-stone-200 group-hover:bg-rose-100 dark:bg-stone-800 dark:group-hover:bg-rose-950 border border-stone-300 group-hover:border-rose-300 dark:border-stone-700 dark:group-hover:border-rose-700 text-stone-700 group-hover:text-rose-800 dark:text-stone-300 dark:group-hover:text-rose-300 text-xs font-bold flex items-center justify-center shrink-0">
2639
+ \${letters[idx]}
2640
+ </span>
2641
+ <span class="text-xs sm:text-sm text-stone-800 dark:text-stone-200 font-medium group-hover:text-stone-950 dark:group-hover:text-white">\${opt}</span>
2642
+ </div>
2643
+ <span id="opt-icon-\${idx}" class="text-xs font-bold opacity-0 transition-opacity"></span>
2644
+ </button>
2645
+ \`;
2646
+ });
2647
+
2648
+ html += \`
2649
+ </div>
2650
+
2651
+ <div id="quiz-feedback" class="hidden p-3.5 rounded-2xl text-xs"></div>
2652
+ </div>
2653
+ \`;
2654
+
2655
+ content.innerHTML = html;
2656
+ }
2657
+
2658
+ async function handleSelectOption(optIdx) {
2659
+ if (quizAnswered) return;
2660
+ quizAnswered = true;
2661
+
2662
+ const questionsList = activeQuizQuestions.length > 0 ? activeQuizQuestions : quizQuestions;
2663
+ const q = questionsList[quizIndex];
2664
+ const i18n = quizI18n[quizLang];
2665
+ const hintText = q.hint[quizLang] || q.hint.en;
2666
+ const feedback = document.getElementById('quiz-feedback');
2667
+ const selectedBtn = document.getElementById('opt-btn-' + optIdx);
2668
+
2669
+ const originalOptionIndex = q.optionOrder ? q.optionOrder[optIdx] : optIdx;
2670
+ const targetQIndex = q.originalIndex !== undefined ? q.originalIndex : quizIndex;
2671
+ userQuizAnswers.push({ qIndex: targetQIndex, answerIndex: originalOptionIndex });
2672
+ quizScore++;
2673
+
2674
+ if (selectedBtn) {
2675
+ selectedBtn.className = 'w-full text-left p-3.5 sm:p-4 rounded-2xl bg-rose-100 dark:bg-rose-950/80 border-2 border-rose-500 text-rose-950 dark:text-white flex items-center justify-between gap-3 shadow-md';
2676
+ const icon = document.getElementById('opt-icon-' + optIdx);
2677
+ if (icon) {
2678
+ icon.innerText = '✓ ' + (quizLang === 'zh' ? '已选择' : (quizLang === 'th' ? 'เลือกแล้ว' : 'Selected'));
2679
+ icon.className = 'text-xs font-bold text-rose-600 dark:text-rose-400 opacity-100';
2680
+ }
2681
+ }
2682
+ if (feedback) {
2683
+ feedback.className = 'p-3.5 rounded-2xl bg-rose-50 dark:bg-rose-950/60 border border-rose-300 dark:border-rose-800 text-rose-900 dark:text-rose-300 text-xs flex items-center gap-2 shadow-xs';
2684
+ feedback.innerHTML = '<span class="text-stone-600 dark:text-stone-300 font-medium">💡 ' + hintText + '</span>';
2685
+ feedback.classList.remove('hidden');
2686
+ }
2687
+
2688
+ setTimeout(() => {
2689
+ quizIndex++;
2690
+ renderQuizQuestion();
2691
+ }, 550);
2692
+ }
2693
+
2694
+ function retryCurrentQuestion() {
2695
+ quizAnswered = false;
2696
+ renderQuizQuestion();
2697
+ }
2698
+
2699
+ async function renderQuizVictory() {
2700
+ const content = document.getElementById('quiz-content');
2701
+ const i18n = quizI18n[quizLang];
2702
+ if (!content) return;
2703
+
2704
+ content.innerHTML = \`
2705
+ <div class="py-8 text-center space-y-4">
2706
+ <div class="inline-block animate-spin text-3xl">🌸</div>
2707
+ <h4 class="text-lg font-bold text-stone-900 dark:text-white">\${i18n.generatingKey}</h4>
2708
+ <p class="text-xs text-stone-500 dark:text-stone-400">\${i18n.generatingDesc}</p>
2709
+ </div>
2710
+ \`;
2711
+
2712
+ try {
2713
+ const res = await fetch('/api/v1/keys/claim', {
2714
+ method: 'POST',
2715
+ headers: { 'Content-Type': 'application/json' },
2716
+ body: JSON.stringify({
2717
+ score: quizScore,
2718
+ answers: userQuizAnswers,
2719
+ challengeToken: currentQuizChallengeToken
2720
+ })
2721
+ });
2722
+ const json = await res.json();
2723
+
2724
+ if (json.status === 'success' && json.data?.apiKey) {
2725
+ const key = json.data.apiKey;
2726
+ currentVipKey = key;
2727
+ updateVipUiState(false);
2728
+
2729
+ content.innerHTML = \`
2730
+ <div class="text-center space-y-6 py-3">
2731
+ <div class="w-16 h-16 mx-auto rounded-3xl bg-gradient-to-br from-amber-400 to-rose-500 p-0.5 shadow-xl shadow-rose-600/30 flex items-center justify-center">
2732
+ <div class="w-full h-full rounded-[22px] bg-white dark:bg-stone-950 flex items-center justify-center text-3xl">
2733
+ 👑
2734
+ </div>
2735
+ </div>
2736
+
2737
+ <div class="space-y-1">
2738
+ <h4 class="text-xl sm:text-2xl font-black text-stone-900 dark:text-transparent dark:bg-clip-text dark:bg-gradient-to-r dark:from-rose-300 dark:via-pink-200 dark:to-amber-200">
2739
+ \${i18n.congratsTitle}
2740
+ </h4>
2741
+ <p class="text-xs text-stone-600 dark:text-stone-300">
2742
+ \${i18n.congratsDesc}
2743
+ </p>
2744
+ </div>
2745
+
2746
+ <div class="p-4 sm:p-5 rounded-2xl bg-stone-50 dark:bg-stone-900 border border-rose-200 dark:border-rose-800/60 text-left space-y-3 shadow-inner">
2747
+ <div class="flex items-center justify-between">
2748
+ <span class="text-[11px] font-bold uppercase tracking-wider text-rose-700 dark:text-rose-400">\${i18n.keyLabel}</span>
2749
+ <span class="text-[10px] px-2 py-0.5 rounded-full bg-emerald-100 dark:bg-emerald-950 text-emerald-800 dark:text-emerald-300 border border-emerald-300 dark:border-emerald-800 font-bold">1,000 req/min</span>
2750
+ </div>
2751
+ <div class="p-3 rounded-xl bg-white dark:bg-stone-950 border border-stone-200 dark:border-stone-800 flex items-center justify-between gap-2 shadow-2xs">
2752
+ <span class="font-mono text-xs text-amber-700 dark:text-amber-300 font-bold break-all select-all">\${key}</span>
2753
+ <button type="button" onclick="copyVipKey('\${key}')" class="px-3 py-1.5 rounded-lg bg-rose-600 hover:bg-rose-500 font-bold text-xs text-white shrink-0 shadow-sm active:scale-95 transition-all cursor-pointer">
2754
+ \${i18n.copyKey}
2755
+ </button>
2756
+ </div>
2757
+ <p class="text-[11px] text-stone-500 dark:text-stone-400">
2758
+ \${quizLang === 'zh' ? '使用方式:' : (quizLang === 'th' ? 'วิธีใช้งาน: ' : 'Usage: ')} <code>X-API-Key: \${key}</code> \${quizLang === 'zh' ? '或' : (quizLang === 'th' ? 'หรือ' : 'or')} <code>Authorization: Bearer \${key}</code>
2759
+ </p>
2760
+ </div>
2761
+
2762
+ <div class="flex flex-col sm:flex-row items-center gap-3 pt-2">
2763
+ <button type="button" onclick="applyVipKeyToConsole(); closeQuizModal();" class="w-full py-3 px-4 rounded-xl bg-gradient-to-r from-rose-600 to-pink-600 hover:from-rose-500 hover:to-pink-500 font-bold text-xs sm:text-sm text-white shadow-lg shadow-rose-600/30 active:scale-95 transition-all cursor-pointer">
2764
+ \${i18n.applyBtn}
2765
+ </button>
2766
+ <button type="button" onclick="closeQuizModal()" class="w-full sm:w-auto py-3 px-5 rounded-xl bg-stone-100 hover:bg-stone-200 dark:bg-stone-900 dark:hover:bg-stone-800 border border-stone-300 dark:border-stone-700 text-stone-800 dark:text-stone-300 font-semibold text-xs transition-colors cursor-pointer">
2767
+ \${i18n.finishBtn}
2768
+ </button>
2769
+ </div>
2770
+ </div>
2771
+ \`;
2772
+ } else {
2773
+ throw new Error(json.message || 'Key generation failed');
2774
+ }
2775
+ } catch (err) {
2776
+ content.innerHTML = \`
2777
+ <div class="text-center py-6 space-y-4">
2778
+ <div class="text-3xl text-rose-500">⚠️</div>
2779
+ <h4 class="text-base font-bold text-stone-900 dark:text-white">\${i18n.errorTitle}</h4>
2780
+ <p class="text-xs text-stone-600 dark:text-stone-300">\${err.message}</p>
2781
+ <button type="button" onclick="openQuizModal()" class="px-4 py-2 rounded-xl bg-rose-600 hover:bg-rose-500 font-bold text-xs text-white cursor-pointer">
2782
+ \${i18n.retryQuiz}
2783
+ </button>
2784
+ </div>
2785
+ \`;
2786
+ }
2787
+ }
2788
+
2789
+ function showToast(options) {
2790
+ const opts = options || {};
2791
+ const icon = opts.icon || '👑';
2792
+ const title = opts.title || '';
2793
+ const message = opts.message || '';
2794
+ const duration = opts.duration || 4500;
2795
+
2796
+ const container = document.getElementById('toast-container');
2797
+ if (!container) return;
2798
+
2799
+ const toast = document.createElement('div');
2800
+ toast.className = 'pointer-events-auto flex items-start gap-3 p-4 rounded-2xl bg-stone-950/95 border border-rose-500/50 shadow-2xl shadow-rose-950/60 backdrop-blur-xl transition-all duration-300 transform translate-y-4 opacity-0 scale-95';
2801
+
2802
+ toast.innerHTML = \`
2803
+ <div class="w-9 h-9 rounded-xl bg-gradient-to-br from-rose-500 via-pink-600 to-amber-500 p-0.5 shrink-0 flex items-center justify-center shadow-md">
2804
+ <div class="w-full h-full rounded-[10px] bg-stone-950 flex items-center justify-center text-base">
2805
+ \${icon}
2806
+ </div>
2807
+ </div>
2808
+ <div class="flex-1 min-w-0">
2809
+ \${title ? \`<h5 class="text-xs font-bold text-white leading-snug">\${title}</h5>\` : ''}
2810
+ <p class="text-xs text-rose-200/90 leading-relaxed mt-0.5 break-words">\${message}</p>
2811
+ </div>
2812
+ <button type="button" class="text-stone-400 hover:text-white text-xs p-1 transition-colors shrink-0 cursor-pointer" onclick="this.parentElement.remove()">
2813
+ ✕
2814
+ </button>
2815
+ \`;
2816
+
2817
+ container.appendChild(toast);
2818
+
2819
+ const triggerAnim = window.requestAnimationFrame || ((cb) => setTimeout(cb, 20));
2820
+ triggerAnim(() => {
2821
+ toast.classList.remove('translate-y-4', 'opacity-0', 'scale-95');
2822
+ toast.classList.add('translate-y-0', 'opacity-100', 'scale-100');
2823
+ });
2824
+
2825
+ setTimeout(() => {
2826
+ toast.classList.remove('translate-y-0', 'opacity-100', 'scale-100');
2827
+ toast.classList.add('translate-y-2', 'opacity-0', 'scale-95');
2828
+ setTimeout(() => {
2829
+ if (toast.parentElement) toast.remove();
2830
+ }, 300);
2831
+ }, duration);
2832
+ }
2833
+
2834
+ function copySnippet(text, btn) {
2835
+ if (!text) return;
2836
+ const copiedLabel = quizLang === 'zh' ? '✓ 已复制' : (quizLang === 'th' ? '✓ คัดลอกแล้ว' : '✓ Copied');
2837
+ const flashSuccess = () => {
2838
+ if (!btn || !btn.isConnected) return;
2839
+ const originalHtml = btn.innerHTML;
2840
+ const originalClass = btn.className;
2841
+ btn.innerHTML = copiedLabel;
2842
+ btn.classList.remove('bg-rose-800/80', 'hover:bg-rose-700', 'bg-purple-800/80', 'hover:bg-purple-700');
2843
+ btn.classList.add('bg-emerald-600/90');
2844
+ setTimeout(() => {
2845
+ if (!btn.isConnected) return;
2846
+ btn.innerHTML = originalHtml;
2847
+ btn.className = originalClass;
2848
+ }, 1600);
2849
+ };
2850
+ const onSuccess = () => {
2851
+ flashSuccess();
2852
+ };
2853
+ if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
2854
+ navigator.clipboard.writeText(text).then(onSuccess).catch(() => {
2855
+ prompt('Copy command:', text);
2856
+ });
2857
+ } else {
2858
+ prompt('Copy command:', text);
2859
+ }
2860
+ }
2861
+
2862
+ function copyVipKey(customKey) {
2863
+ const keyToCopy = customKey || currentVipKey;
2864
+ if (!keyToCopy) return;
2865
+
2866
+ const onCopySuccess = () => {
2867
+ showToast({
2868
+ icon: '📋',
2869
+ title: quizLang === 'zh' ? '已复制密钥' : (quizLang === 'th' ? 'คัดลอก VIP Key แล้ว' : 'Key Copied!'),
2870
+ message: quizLang === 'zh' ? '开发者 VIP Key 已成功复制到剪贴板' : (quizLang === 'th' ? 'คัดลอก Developer VIP Key ลงในคลิปบอร์ดแล้ว' : 'Developer VIP Key copied to clipboard!'),
2871
+ duration: 4000
2872
+ });
2873
+ };
2874
+
2875
+ const fallbackCopy = () => {
2876
+ try {
2877
+ const textArea = document.createElement('textarea');
2878
+ textArea.value = keyToCopy;
2879
+ textArea.style.position = 'fixed';
2880
+ textArea.style.left = '-999999px';
2881
+ textArea.style.top = '-999999px';
2882
+ document.body.appendChild(textArea);
2883
+ textArea.focus();
2884
+ textArea.select();
2885
+ const successful = document.execCommand('copy');
2886
+ document.body.removeChild(textArea);
2887
+ if (successful) {
2888
+ onCopySuccess();
2889
+ } else {
2890
+ prompt('Copy your VIP Key:', keyToCopy);
2891
+ }
2892
+ } catch {
2893
+ prompt('Copy your VIP Key:', keyToCopy);
2894
+ }
2895
+ };
2896
+
2897
+ if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
2898
+ navigator.clipboard.writeText(keyToCopy).then(() => {
2899
+ onCopySuccess();
2900
+ }).catch(() => {
2901
+ fallbackCopy();
2902
+ });
2903
+ } else {
2904
+ fallbackCopy();
2905
+ }
2906
+ }
2907
+
2908
+ function applyVipKeyToConsole() {
2909
+ if (!currentVipKey) return;
2910
+ updateVipUiState(false);
2911
+ const i18n = quizI18n[quizLang];
2912
+ showToast({
2913
+ icon: '👑',
2914
+ title: quizLang === 'zh' ? 'VIP Key 已生效!' : (quizLang === 'th' ? 'นำ VIP Key ไปใช้งานแล้ว!' : 'VIP Key Applied!'),
2915
+ message: i18n.appliedAlert,
2916
+ duration: 5000
2917
+ });
2918
+ }
2919
+
2920
+ async function updateVipUiState(verifyOnline) {
2921
+ const activeBadge = document.getElementById('active-tier-badge');
2922
+ const badgeLabel = document.getElementById('tier-badge-label');
2923
+ const startBtn = document.getElementById('start-quiz-btn');
2924
+ const display = document.getElementById('vip-key-display');
2925
+ const keyVal = document.getElementById('vip-key-val');
2926
+
2927
+ if (!currentVipKey) {
2928
+ if (display) display.classList.add('hidden');
2929
+ if (badgeLabel) badgeLabel.innerText = '🟢 Public Tier Active (60 req/min)';
2930
+ if (activeBadge) activeBadge.className = 'self-start sm:self-auto inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-emerald-950/80 border border-emerald-800/60 text-emerald-300 text-xs font-semibold';
2931
+ if (startBtn) startBtn.innerText = '🌸 Take Ju Jingyi Quiz & Unlock VIP Key (15 Questions)';
2932
+ return;
2933
+ }
2934
+
2935
+ if (verifyOnline) {
2936
+ try {
2937
+ const res = await fetch('/api/v1/health', {
2938
+ headers: { 'X-API-Key': currentVipKey }
2939
+ });
2940
+ const json = await res.json();
2941
+ if (!res.ok || json.data?.tier !== 'Developer VIP') {
2942
+ currentVipKey = '';
2943
+ if (display) display.classList.add('hidden');
2944
+ if (badgeLabel) badgeLabel.innerText = '🟢 Public Tier Active (60 req/min)';
2945
+ if (activeBadge) activeBadge.className = 'self-start sm:self-auto inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-emerald-950/80 border border-emerald-800/60 text-emerald-300 text-xs font-semibold';
2946
+ if (startBtn) startBtn.innerText = '🌸 Take Ju Jingyi Quiz & Unlock VIP Key (15 Questions)';
2947
+ return;
2948
+ }
2949
+ } catch {
2950
+
2951
+ }
2952
+ }
2953
+
2954
+ if (badgeLabel) badgeLabel.innerText = '👑 Developer VIP Tier Active (1,000 req/min)';
2955
+ if (activeBadge) activeBadge.className = 'self-start sm:self-auto inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-950/80 border border-amber-800/60 text-amber-300 text-xs font-bold';
2956
+ if (startBtn) startBtn.innerText = '🌸 Retake Quiz (15/15 Passed)';
2957
+ if (display) display.classList.remove('hidden');
2958
+ if (keyVal) keyVal.innerText = currentVipKey;
2959
+
2960
+
2961
+ document.querySelectorAll('input[id^="key-ep-"]').forEach(inp => {
2962
+ inp.value = currentVipKey;
2963
+ });
2964
+ }
2965
+
2966
+ function toggleAllEndpoints() {
2967
+ allExpanded = !allExpanded;
2968
+ if (typeof endpoints !== 'undefined') {
2969
+ endpoints.forEach((group, gIdx) => {
2970
+ group.items.forEach((_, iIdx) => {
2971
+ const id = 'ep-' + gIdx + '-' + iIdx;
2972
+ const el = document.getElementById(id);
2973
+ const chev = document.getElementById('chev-' + id);
2974
+ if (el) {
2975
+ if (allExpanded) {
2976
+ el.classList.remove('hidden');
2977
+ } else {
2978
+ el.classList.add('hidden');
2979
+ }
2980
+ }
2981
+ if (chev) {
2982
+ chev.style.transform = allExpanded ? 'rotate(180deg)' : 'rotate(0deg)';
2983
+ }
2984
+ });
2985
+ });
2986
+ }
2987
+ const btnText = document.getElementById('toggle-all-text');
2988
+ if (btnText) {
2989
+ btnText.innerText = allExpanded ? '⚡ Collapse All Testers' : '⚡ Click to Test Live (Expand All)';
2990
+ }
2991
+ }
2992
+
2993
+ function toggleEndpoint(id) {
2994
+ const el = document.getElementById(id);
2995
+ const chev = document.getElementById('chev-' + id);
2996
+ if (el) {
2997
+ const isHidden = el.classList.toggle('hidden');
2998
+ if (chev) {
2999
+ chev.style.transform = isHidden ? 'rotate(0deg)' : 'rotate(180deg)';
3000
+ }
3001
+ }
3002
+ }
3003
+
3004
+ async function executeLiveRequest(id, reqMethod) {
3005
+ const input = document.getElementById('input-' + id);
3006
+ const output = document.getElementById('output-' + id);
3007
+ const status = document.getElementById('status-' + id);
3008
+ const timing = document.getElementById('timing-' + id);
3009
+ const keyInput = document.getElementById('key-' + id);
3010
+ const bodyInput = document.getElementById('body-' + id);
3011
+
3012
+ if (!input || !output) return;
3013
+
3014
+ let path = input.value.trim();
3015
+ if (!path.startsWith('/')) path = '/' + path;
3016
+ const targetUrl = '/api/v1' + path;
3017
+ const method = (reqMethod || 'GET').toUpperCase();
3018
+ const userKey = (keyInput ? keyInput.value.trim() : '') || currentVipKey;
3019
+
3020
+
3021
+ if (path === '/keys/generate' && !userKey) {
3022
+ if (status) status.innerHTML = '<span class="text-amber-400 font-bold">⚠️ Key Required (401)</span>';
3023
+ output.innerText = JSON.stringify({
3024
+ status: "error",
3025
+ code: 401,
3026
+ message: "API Key is required to call /keys/generate.",
3027
+ recommendation: "Please enter a valid key in the 'Header: X-API-Key' input above, or complete the Ju Jingyi 15-Question Quiz to claim your Developer VIP Key (1,000 req/min)!"
3028
+ }, null, 2);
3029
+ return;
3030
+ }
3031
+
3032
+ if (path === '/keys/claim' && method === 'POST') {
3033
+ let parsed = {};
3034
+ try {
3035
+ parsed = JSON.parse(bodyInput ? bodyInput.value.trim() : '{}');
3036
+ } catch {
3037
+ parsed = {};
3038
+ }
3039
+
3040
+
3041
+ if (Array.isArray(userQuizAnswers) && userQuizAnswers.length === 15) {
3042
+ bodyInput.value = JSON.stringify({ score: 15, answers: userQuizAnswers }, null, 2);
3043
+ } else if (!parsed.score || parsed.score < 15 || !Array.isArray(parsed.answers) || parsed.answers.length < 15) {
3044
+ if (status) status.innerHTML = '<span class="text-rose-400 font-bold">🌸 Quiz Required (403)</span>';
3045
+ output.innerText = JSON.stringify({
3046
+ status: "error",
3047
+ code: 403,
3048
+ message: "Quiz verification required. You must complete the 15-Question Ju Jingyi Trivia Challenge to claim a Developer VIP Key (1,000 req/min).",
3049
+ recommendation: "Please click the '🌸 Launch 15-Question Quiz' button above or in the hero banner to start the quiz!"
3050
+ }, null, 2);
3051
+ return;
3052
+ }
3053
+ }
3054
+
3055
+ if (status) status.innerHTML = '<span class="text-amber-400 font-semibold">Sending ' + method + ' request...</span>';
3056
+ output.innerText = 'Loading live data from ' + targetUrl + '...';
3057
+ const startTime = performance.now();
3058
+
3059
+ try {
3060
+ const headers = {};
3061
+ if (userKey) {
3062
+ headers['X-API-Key'] = userKey;
3063
+ }
3064
+
3065
+ let bodyPayload = undefined;
3066
+ if (method === 'POST') {
3067
+ headers['Content-Type'] = 'application/json';
3068
+ bodyPayload = bodyInput ? bodyInput.value.trim() : '{\\n "score": 15,\\n "answers": ["A","B","C","D","A","B","C","D","A","B","C","D","A","B","C"]\\n}';
3069
+ if (!bodyPayload) bodyPayload = '{\\n "score": 15,\\n "answers": ["A","B","C","D","A","B","C","D","A","B","C","D","A","B","C"]\\n}';
3070
+ }
3071
+
3072
+ const res = await fetch(targetUrl, {
3073
+ method: method,
3074
+ headers: headers,
3075
+ body: bodyPayload
3076
+ });
3077
+
3078
+ const elapsed = (performance.now() - startTime).toFixed(1);
3079
+ const data = await res.json();
3080
+
3081
+ if (timing) timing.innerText = elapsed + ' ms';
3082
+ if (status) {
3083
+ const limitTier = res.headers.get('X-RateLimit-Tier') || (userKey ? 'Developer-VIP (1000/min)' : 'Public (60/min)');
3084
+ if (res.ok) {
3085
+ status.innerHTML = '<span class="text-emerald-400 font-bold">HTTP ' + res.status + ' OK</span> <span class="text-stone-400 font-mono text-[10px] ml-1">[' + limitTier + ']</span>';
3086
+ if (path === '/keys/claim' && data.data?.apiKey) {
3087
+ currentVipKey = data.data.apiKey;
3088
+ updateVipUiState();
3089
+ }
3090
+ } else if (res.status === 401) {
3091
+ status.innerHTML = '<span class="text-amber-400 font-bold">HTTP 401 Unauthorized</span> <span class="text-stone-400 text-[10px] ml-1">(API Key Required)</span>';
3092
+ } else if (res.status === 403) {
3093
+ status.innerHTML = '<span class="text-rose-400 font-bold">HTTP 403 Forbidden</span> <span class="text-stone-400 text-[10px] ml-1">(Quiz 15/15 Required)</span>';
3094
+ } else {
3095
+ status.innerHTML = '<span class="text-rose-400 font-bold">HTTP ' + res.status + ' ' + (res.statusText || '') + '</span>';
3096
+ }
3097
+ }
3098
+
3099
+ output.innerText = JSON.stringify(data, null, 2);
3100
+ } catch (err) {
3101
+ if (status) status.innerHTML = '<span class="text-rose-500 font-bold">Network Error</span>';
3102
+ output.innerText = String(err);
3103
+ }
3104
+ }
3105
+
3106
+ window.toggleAllEndpoints = toggleAllEndpoints;
3107
+ window.toggleEndpoint = toggleEndpoint;
3108
+ window.executeLiveRequest = executeLiveRequest;
3109
+ window.openQuizModal = openQuizModal;
3110
+ window.closeQuizModal = closeQuizModal;
3111
+ window.setQuizLang = setQuizLang;
3112
+ window.handleSelectOption = handleSelectOption;
3113
+ window.retryCurrentQuestion = retryCurrentQuestion;
3114
+ window.copyVipKey = copyVipKey;
3115
+ window.applyVipKeyToConsole = applyVipKeyToConsole;
3116
+ window.showToast = showToast;
3117
+
3118
+ const endpoints = [
3119
+ {
3120
+ tag: "System, Stats & API Keys",
3121
+ items: [
3122
+ { method: "GET", path: "/health", desc: "API health, uptime, and database status (Public & VIP)", example: "/health" },
3123
+ { method: "GET", path: "/stats", desc: "Platform statistical overview & catalog counts", example: "/stats" },
3124
+ { method: "GET", path: "/quiz/challenge", desc: "Request one-time anti-cheat challenge token for VIP quiz", example: "/quiz/challenge" },
3125
+ { method: "POST", path: "/keys/claim", desc: "Claim Developer VIP Key upon 15/15 quiz completion", example: "/keys/claim", defaultBody: '{\\n "score": 15,\\n "answers": ["A", "B", "C", "D", "A", "B", "C", "D", "A", "B", "C", "D", "A", "B", "C"]\\n}', requiresQuiz: true },
3126
+ { method: "GET", path: "/keys/generate", desc: "Generate Developer API Key (Requires Master Key in X-API-Key)", example: "/keys/generate", requiresKey: true },
3127
+ { method: "GET", path: "/openapi.json", desc: "Full OpenAPI 3.0.0 JSON Specification", example: "/openapi.json" }
3128
+ ]
3129
+ },
3130
+ {
3131
+ tag: "Profile & Fandom",
3132
+ items: [
3133
+ { method: "GET", path: "/profile", desc: "Complete biography, talents, physical profile, and social channels", example: "/profile" },
3134
+ { method: "GET", path: "/fandom", desc: "Miju fan-community profile, official colors, and mottos", example: "/fandom" },
3135
+ { method: "GET", path: "/quotes", desc: "Inspirational quotes and life philosophies by Ju Jingyi", example: "/quotes" }
3136
+ ]
3137
+ },
3138
+ {
3139
+ tag: "Filmography & Character Roles",
3140
+ items: [
3141
+ { method: "GET", path: "/works", desc: "Complete filmography with category, year, and search filters", example: "/works?category=historical-drama&year=2024" },
3142
+ { method: "GET", path: "/works/:id", desc: "Single drama/movie details by ID", example: "/works/in-blossom" },
3143
+ { method: "GET", path: "/character-roles", desc: "List of all iconic dramatic character roles portrayed", example: "/character-roles" }
3144
+ ]
3145
+ },
3146
+ {
3147
+ tag: "Music & Discography",
3148
+ items: [
3149
+ { method: "GET", path: "/discography", desc: "Deduplicated catalog of 45+ tracks sorted by year", example: "/discography?year=2026" },
3150
+ { method: "GET", path: "/discography/:id", desc: "Single song details with duration and YouTube ID", example: "/discography/find-the-way" },
3151
+ { method: "GET", path: "/mv", desc: "Official 4K music videos and live stage performances", example: "/mv?category=Stage-Performance" }
3152
+ ]
3153
+ },
3154
+ {
3155
+ tag: "Commercial & Career Chronicle",
3156
+ items: [
3157
+ { method: "GET", path: "/brands", desc: "Commercial endorsements and Global Ambassador brands", example: "/brands?isGlobal=true" },
3158
+ { method: "GET", path: "/timeline", desc: "Career milestones from SNH48 debut (2013) to present (2026)", example: "/timeline?era=snh48" },
3159
+ { method: "GET", path: "/awards", desc: "Honors, SNH48 election records, and acting accolades", example: "/awards" }
3160
+ ]
3161
+ },
3162
+ {
3163
+ tag: "Visual Gallery, Social & Search",
3164
+ items: [
3165
+ { method: "GET", path: "/gallery", desc: "High-resolution photos with category & limit filters", example: "/gallery?limit=6" },
3166
+ { method: "GET", path: "/social", desc: "Douyin & Weibo official social media feed cards", example: "/social?platform=douyin" },
3167
+ { method: "GET", path: "/search", desc: "Global fuzzy search across all 9 data categories", example: "/search?q=In+Blossom" }
3168
+ ]
3169
+ }
3170
+ ];
3171
+
3172
+ function renderEndpointsList() {
3173
+ const container = document.getElementById('endpoint-list');
3174
+ if (!container) return;
3175
+ let html = '';
3176
+
3177
+ endpoints.forEach((group, gIdx) => {
3178
+ html += \`<div class="space-y-2 pt-2">
3179
+ <h3 class="text-xs font-bold uppercase tracking-wider text-rose-700 dark:text-rose-300 flex items-center gap-1.5">
3180
+ <span class="w-1.5 h-1.5 rounded-full bg-rose-500"></span>
3181
+ \${group.tag}
3182
+ </h3>
3183
+ <div class="space-y-2">\`;
3184
+
3185
+ group.items.forEach((item, iIdx) => {
3186
+ const id = \`ep-\${gIdx}-\${iIdx}\`;
3187
+ const methodColor = item.method === 'POST'
3188
+ ? 'bg-amber-100 text-amber-900 border-amber-300 dark:bg-amber-950/80 dark:text-amber-300 dark:border-amber-800/50'
3189
+ : 'bg-emerald-100 text-emerald-800 border-emerald-300 dark:bg-emerald-950/80 dark:text-emerald-300 dark:border-emerald-800/50';
3190
+
3191
+ html += \`
3192
+ <div class="rounded-2xl bg-white dark:bg-stone-900/90 border border-rose-200/80 dark:border-stone-800/80 hover:border-rose-400 dark:hover:border-rose-800/50 transition-all overflow-hidden shadow-xs">
3193
+ <div class="p-3.5 sm:p-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 cursor-pointer group" onclick="toggleEndpoint('\${id}')">
3194
+ <div class="flex items-center gap-2.5 sm:gap-3 flex-wrap">
3195
+ <span class="px-2.5 py-1 rounded-lg font-mono text-xs font-bold border \${methodColor}">\${item.method}</span>
3196
+ <span class="font-mono text-stone-900 dark:text-rose-200 text-sm font-semibold group-hover:text-rose-600 dark:group-hover:text-rose-100 transition-colors">/api/v1\${item.path}</span>
3197
+ <span class="text-xs text-stone-600 dark:text-stone-400">\${item.desc}</span>
3198
+ </div>
3199
+ <div class="flex items-center gap-2 shrink-0 self-end sm:self-center">
3200
+ \${item.requiresKey ? '<span class="text-[10px] px-2 py-0.5 rounded bg-amber-100 dark:bg-amber-950/80 border border-amber-300 dark:border-amber-800/60 text-amber-900 dark:text-amber-300 font-mono">🔑 Key Required</span>' : ''}
3201
+ \${item.requiresQuiz ? '<span class="text-[10px] px-2 py-0.5 rounded bg-rose-100 dark:bg-rose-950/80 border border-rose-300 dark:border-rose-800/60 text-rose-800 dark:text-rose-300 font-mono">🌸 15/15 Quiz</span>' : ''}
3202
+ <button type="button" onclick="event.stopPropagation(); toggleEndpoint('\${id}')" class="px-3 py-1 rounded-full bg-rose-50 hover:bg-rose-100 dark:bg-rose-950/60 dark:hover:bg-rose-900/60 border border-rose-200 dark:border-rose-800/50 text-xs font-semibold text-rose-700 dark:text-rose-300 hover:text-rose-950 dark:hover:text-white transition-all cursor-pointer">
3203
+ Try Live ⚡
3204
+ </button>
3205
+ <svg id="chev-\${id}" class="w-4 h-4 text-stone-400 dark:text-stone-500 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
3206
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
3207
+ </svg>
3208
+ </div>
3209
+ </div>
3210
+
3211
+ <div id="\${id}" class="hidden border-t border-rose-200/80 dark:border-stone-800/80 bg-stone-50/70 dark:bg-stone-950/80 p-4 space-y-3">
3212
+ <div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
3213
+ <div class="flex-1 flex items-center rounded-xl bg-white dark:bg-stone-900/90 border border-stone-300 dark:border-stone-700 px-3 py-2 shadow-xs">
3214
+ <span class="text-xs font-mono font-bold \${item.method === 'POST' ? 'text-amber-600 dark:text-amber-400' : 'text-emerald-600 dark:text-emerald-400'} mr-2 select-none">\${item.method} /api/v1</span>
3215
+ <input type="text" id="input-\${id}" value="\${item.example}" onkeydown="if(event.key==='Enter') executeLiveRequest('\${id}', '\${item.method}')" class="bg-transparent font-mono text-xs text-stone-900 dark:text-white focus:outline-none w-full" />
3216
+ </div>
3217
+ <button type="button" onclick="executeLiveRequest('\${id}', '\${item.method}')" class="px-4 py-2 rounded-xl bg-gradient-to-r from-rose-600 to-pink-600 hover:from-rose-500 hover:to-pink-500 font-bold text-xs text-white shadow-md shadow-rose-600/30 active:scale-95 transition-all shrink-0 cursor-pointer">
3218
+ Send Request
3219
+ </button>
3220
+ </div>
3221
+
3222
+ <div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 text-xs">
3223
+ <div class="flex-1 flex items-center rounded-xl bg-white dark:bg-stone-900/60 border border-stone-300 dark:border-stone-800 px-3 py-1.5 shadow-2xs">
3224
+ <span class="text-[11px] font-mono text-stone-500 dark:text-stone-400 mr-2 select-none shrink-0">Header: X-API-Key</span>
3225
+ <input type="text" id="key-\${id}" value="\${currentVipKey || ''}" placeholder="Optional (Leave empty for Public Tier 60/min, or provide VIP Key for 1,000/min)" class="bg-transparent font-mono text-xs text-amber-700 dark:text-amber-300 focus:outline-none w-full placeholder:text-stone-400 dark:placeholder:text-stone-600" />
3226
+ </div>
3227
+ \${item.requiresKey ? \`
3228
+ <div class="flex items-center gap-1.5 shrink-0">
3229
+ <span class="text-[11px] text-amber-700 dark:text-amber-300 font-mono font-bold">🔑 Protected</span>
3230
+ <button type="button" onclick="openQuizModal()" class="px-2.5 py-1 rounded-lg bg-gradient-to-r from-rose-600 to-amber-500 text-white font-bold text-[11px] shadow-xs hover:opacity-90 transition-opacity cursor-pointer">
3231
+ 🌸 Unlock VIP Key
3232
+ </button>
3233
+ </div>
3234
+ \` : ''}
3235
+ \${item.requiresQuiz ? \`
3236
+ <button type="button" onclick="openQuizModal()" class="px-2.5 py-1.5 rounded-lg bg-rose-600 hover:bg-rose-500 dark:bg-rose-900/60 dark:hover:bg-rose-800/80 border border-rose-500 dark:border-rose-700/60 text-white dark:text-rose-200 text-xs font-bold shrink-0 transition-colors cursor-pointer">
3237
+ 🌸 Launch 15-Question Quiz
3238
+ </button>
3239
+ \` : ''}
3240
+ </div>
3241
+
3242
+ \${item.method === 'POST' ? \`
3243
+ <div class="space-y-1">
3244
+ <div class="flex items-center justify-between text-[11px] text-stone-600 dark:text-stone-400">
3245
+ <span>Request Payload (JSON Body):</span>
3246
+ <span class="text-[10px] font-mono text-stone-500">Content-Type: application/json</span>
3247
+ </div>
3248
+ <textarea id="body-\${id}" rows="2" class="w-full p-2.5 rounded-xl bg-stone-900 border border-stone-700 font-mono text-xs text-emerald-300 focus:outline-none focus:border-rose-500 transition-colors">\${item.defaultBody || '{\\n "score": 15\\n}'}</textarea>
3249
+ </div>
3250
+ \` : ''}
3251
+
3252
+ <div class="space-y-1.5 pt-1">
3253
+ <div class="flex items-center justify-between text-[11px] text-stone-600 dark:text-stone-400">
3254
+ <span id="status-\${id}">Ready</span>
3255
+ <span id="timing-\${id}" class="font-mono text-stone-500 dark:text-stone-400"></span>
3256
+ </div>
3257
+ <pre id="output-\${id}" class="p-3.5 rounded-xl bg-[#0c0814] border border-stone-800 text-[11px] font-mono text-stone-200 overflow-x-auto max-h-64 shadow-inner">Click "Send Request" to test live...</pre>
3258
+ </div>
3259
+ </div>
3260
+ </div>
3261
+ \`;
3262
+ });
3263
+
3264
+ html += \`</div></div>\`;
3265
+ });
3266
+
3267
+ container.innerHTML = html;
3268
+ }
3269
+
3270
+ if (document.readyState === 'loading') {
3271
+ document.addEventListener('DOMContentLoaded', () => {
3272
+ updateThemeIcons();
3273
+ renderEndpointsList();
3274
+ updateVipUiState(true);
3275
+ });
3276
+ } else {
3277
+ updateThemeIcons();
3278
+ renderEndpointsList();
3279
+ updateVipUiState(true);
3280
+ }
3281
+ </script>
3282
+ </body>
3283
+ </html>`;
3284
+ }
3285
+
3286
+ function safeDecode(str) {
3287
+ if (!str) return '';
3288
+ try {
3289
+ return decodeURIComponent(str);
3290
+ } catch {
3291
+ return str;
3292
+ }
3293
+ }
3294
+
3295
+ export async function handleApiRequest(req, res, next) {
3296
+ const trustProxy = process.env.VERCEL === '1' || process.env.KIKU_TRUST_PROXY === 'true';
3297
+ const rawHost = trustProxy ? (req.headers['x-forwarded-host'] || req.headers.host) : req.headers.host;
3298
+ const hostCandidate = String(rawHost || '').split(',')[0].trim();
3299
+ const host = /^[a-z0-9.-]+(?::\d{1,5})?$/i.test(hostCandidate)
3300
+ ? hostCandidate
3301
+ : 'jujingyi-kikuuu.vercel.app';
3302
+ const isLocal = host.includes('localhost') || host.includes('127.0.0.1') || host.endsWith('.local');
3303
+ const forwardedProtocol = trustProxy ? String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() : '';
3304
+ const protocol = ['http', 'https'].includes(forwardedProtocol) ? forwardedProtocol : (isLocal ? 'http' : 'https');
3305
+ const baseUrl = `${protocol}://${host}`;
3306
+ const { isConfigured: isSupabaseConfigured } = getSupabaseConfig();
3307
+
3308
+ const rawPath = req.url || req.headers['x-matched-path'] || '/';
3309
+ const urlObj = new URL(rawPath, baseUrl);
3310
+ let pathname = urlObj.pathname;
3311
+
3312
+ if (pathname === '/api/api-docs' || pathname === '/api/api-docs/') {
3313
+ pathname = '/api-docs';
3314
+ }
3315
+ if (pathname.length > 1 && pathname.endsWith('/')) {
3316
+ pathname = pathname.slice(0, -1);
3317
+ }
3318
+
3319
+ if (pathname === '/api-docs') {
3320
+ res.statusCode = 200;
3321
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
3322
+ res.setHeader('Cache-Control', 'no-cache');
3323
+ applySecurityHeaders(res, true);
3324
+ res.end(renderApiDocsHtml());
3325
+ return;
3326
+ }
3327
+
3328
+ if (pathname === '/api/v1/openapi.json' || pathname === '/api/v1/swagger.json') {
3329
+ return sendJson(res, 200, getOpenApiSpec(baseUrl));
3330
+ }
3331
+
3332
+ if (!pathname.startsWith('/api/v1') && pathname !== '/api' && !pathname.startsWith('/mcp')) {
3333
+ if (next) return next();
3334
+ return;
3335
+ }
3336
+
3337
+ if (req.method === 'OPTIONS') {
3338
+ res.statusCode = 204;
3339
+ applySecurityHeaders(res, false);
3340
+ res.end();
3341
+ return;
3342
+ }
3343
+
3344
+ const clientIp = resolveClientIp(req);
3345
+ const searchParams = urlObj.searchParams;
3346
+ const apiKey = extractApiKey(req);
3347
+ const validKeyInfo = apiKey ? await validateApiKey(apiKey) : null;
3348
+ const hasValidApiKey = Boolean(validKeyInfo);
3349
+ const hasAgentExecutionAccess = Boolean(apiKey && (isMasterKey(apiKey) || agentExecutionKeys.has(apiKey.trim())));
3350
+
3351
+ const baseRateLimit = validKeyInfo?.rateLimit || (hasValidApiKey ? RATE_LIMIT_API_KEY : RATE_LIMIT_ANONYMOUS);
3352
+ const parsedTestLimit = Number.parseInt(searchParams.get('testRateLimit') || '', 10);
3353
+ const hasTestRateLimit = Number.isInteger(parsedTestLimit) && parsedTestLimit > 0;
3354
+ const maxLimit = hasTestRateLimit ? Math.min(parsedTestLimit, baseRateLimit) : baseRateLimit;
3355
+ const baseClientIdentifier = hasValidApiKey ? `key:${hashApiKey(apiKey)}` : `ip:${clientIp}`;
3356
+ const clientIdentifier = hasTestRateLimit
3357
+ ? `${baseClientIdentifier}:test:${maxLimit}`
3358
+ : baseClientIdentifier;
3359
+ const rateInfo = await checkDistributedRateLimit(clientIdentifier, maxLimit);
3360
+
3361
+ const keyTierLabel = validKeyInfo?.tier || (hasValidApiKey ? 'Developer-VIP' : 'Public-Anonymous');
3362
+ const rateHeaders = {
3363
+ 'X-RateLimit-Limit': String(rateInfo.limit),
3364
+ 'X-RateLimit-Remaining': String(rateInfo.remaining),
3365
+ 'X-RateLimit-Reset': String(rateInfo.reset),
3366
+ 'X-RateLimit-Tier': `${keyTierLabel} (${rateInfo.limit}/min)`
3367
+ };
3368
+
3369
+ if (!rateInfo.allowed) {
3370
+ rateHeaders['Retry-After'] = String(rateInfo.retryAfter);
3371
+ return sendJson(res, 429, {
3372
+ status: 'error',
3373
+ code: 429,
3374
+ timestamp: new Date().toISOString(),
3375
+ message: `Too Many Requests. Rate limit of ${rateInfo.limit} requests/minute exceeded.`,
3376
+ retryAfterSeconds: rateInfo.retryAfter
3377
+ }, rateHeaders);
3378
+ }
3379
+
3380
+ const timestamp = new Date().toISOString();
3381
+
3382
+ if (pathname === '/api/v1/health' || pathname === '/api/v1') {
3383
+ return sendJson(res, 200, {
3384
+ status: 'success',
3385
+ code: 200,
3386
+ timestamp,
3387
+ data: {
3388
+ apiName: 'Ju Jingyi Official Chronicle REST API',
3389
+ version: `v${PACKAGE_VERSION}`,
3390
+ status: 'operational',
3391
+ environment: isLocal ? 'local-development' : 'production',
3392
+ isLocal,
3393
+ database: isSupabaseConfigured ? 'supabase-postgresql (connected)' : 'in-memory (standalone)',
3394
+ supabaseConfigured: isSupabaseConfigured,
3395
+ currentHost: host,
3396
+ uptimeSeconds: Math.floor(process.uptime()),
3397
+ endpointsCount: 19,
3398
+ tier: hasValidApiKey ? keyTierLabel : 'Public',
3399
+ baseUrl: `${baseUrl}/api/v1`,
3400
+ documentation: `${baseUrl}/api-docs`,
3401
+ openApiSpec: `${baseUrl}/api/v1/openapi.json`,
3402
+ availableServers: [
3403
+ {
3404
+ url: `${baseUrl}/api/v1`,
3405
+ description: isLocal ? 'Active Localhost (Auto-detected)' : 'Active Production (Auto-detected)'
3406
+ },
3407
+ {
3408
+ url: 'https://jujingyi-kikuuu.vercel.app/api/v1',
3409
+ description: 'Production Vercel Cloud CDN'
3410
+ },
3411
+ {
3412
+ url: 'http://localhost:3000/api/v1',
3413
+ description: 'Local Node.js Dev Server'
3414
+ }
3415
+ ]
3416
+ }
3417
+ }, rateHeaders);
3418
+ }
3419
+
3420
+ if (pathname === '/api/v1/quiz/challenge') {
3421
+ const session = createQuizChallengeToken();
3422
+ return sendJson(res, 200, {
3423
+ status: 'success',
3424
+ code: 200,
3425
+ timestamp,
3426
+ data: {
3427
+ challengeToken: session.challengeToken,
3428
+ expiresAt: session.expiresAt,
3429
+ totalQuestions: 15,
3430
+ targetTier: 'Developer VIP Tier (1,000 req/min)',
3431
+ usage: "Send this challengeToken along with your 15 completed quiz answers to POST /api/v1/keys/claim"
3432
+ }
3433
+ }, { ...rateHeaders, 'Cache-Control': 'no-store', Pragma: 'no-cache' });
3434
+ }
3435
+
3436
+ if (pathname === '/api/v1/keys/generate') {
3437
+ const adminKey = extractApiKey(req);
3438
+ const adminKeyValid = adminKey ? await validateApiKey(adminKey) : null;
3439
+ const isAuthorized = isMasterKey(adminKey) || (adminKeyValid && adminKeyValid.tier === 'Master VIP');
3440
+ if (!adminKey || !adminKeyValid || !isAuthorized) {
3441
+ return sendJson(res, 401, {
3442
+ status: 'error',
3443
+ code: 401,
3444
+ timestamp,
3445
+ message: 'Unauthorized. Admin/Master API Key required in X-API-Key header to generate new developer keys.'
3446
+ }, rateHeaders);
3447
+ }
3448
+ const token = crypto.randomBytes(16).toString('hex');
3449
+ const newKey = `kiku_dev_sk_${token}`;
3450
+ await saveApiKeyToSupabase({ apiKey: newKey, tier: 'developer', rateLimit: 1000 });
3451
+ return sendJson(res, 200, {
3452
+ status: 'success',
3453
+ code: 200,
3454
+ timestamp,
3455
+ data: {
3456
+ apiKey: newKey,
3457
+ tier: 'Developer Tier (1,000 requests / minute)',
3458
+ rateLimit: 1000,
3459
+ storage: isSupabaseConfigured ? 'supabase-postgresql (persistent)' : 'in-memory (standalone)',
3460
+ usageInstruction: "Attach header 'X-API-Key: <your_key>' or 'Authorization: Bearer <your_key>'"
3461
+ }
3462
+ }, { ...rateHeaders, 'Cache-Control': 'no-store', Pragma: 'no-cache' });
3463
+ }
3464
+
3465
+ if (pathname === '/api/v1/keys/claim') {
3466
+ if (req.method !== 'POST') {
3467
+ return sendJson(res, 405, {
3468
+ status: 'error',
3469
+ code: 405,
3470
+ timestamp,
3471
+ message: 'Method Not Allowed. Use POST to claim Developer VIP Key with verified quiz answers.'
3472
+ }, rateHeaders);
3473
+ }
3474
+
3475
+ let body = {};
3476
+ try {
3477
+ body = await parseJsonBody(req);
3478
+ } catch {
3479
+ body = {};
3480
+ }
3481
+
3482
+ if (!body.challengeToken) {
3483
+ return sendJson(res, 403, {
3484
+ status: 'error',
3485
+ code: 403,
3486
+ timestamp,
3487
+ message: 'Quiz challenge token is required. Please complete the quiz flow from the beginning.'
3488
+ }, rateHeaders);
3489
+ }
3490
+
3491
+ if (!verifyQuizChallengeToken(body.challengeToken)) {
3492
+ return sendJson(res, 403, {
3493
+ status: 'error',
3494
+ code: 403,
3495
+ timestamp,
3496
+ message: 'Invalid, expired, or already claimed quiz challenge token. Please launch the quiz again to obtain a fresh challenge session.'
3497
+ }, rateHeaders);
3498
+ }
3499
+
3500
+ const answers = Array.isArray(body.answers) ? body.answers : [];
3501
+
3502
+ if (!verifyQuizAnswers(answers)) {
3503
+ return sendJson(res, 403, {
3504
+ status: 'error',
3505
+ code: 403,
3506
+ timestamp,
3507
+ message: 'Quiz verification failed. All 15 answers must be correct to claim a Developer VIP Key. Please try again.'
3508
+ }, rateHeaders);
3509
+ }
3510
+
3511
+ consumeQuizChallengeToken(body.challengeToken);
3512
+
3513
+ const token = crypto.randomBytes(16).toString('hex');
3514
+ const newVipKey = `kiku_vip_sk_${token}`;
3515
+ await saveApiKeyToSupabase({ apiKey: newVipKey, tier: 'developer_vip', rateLimit: 1000 });
3516
+
3517
+ return sendJson(res, 200, {
3518
+ status: 'success',
3519
+ code: 200,
3520
+ timestamp,
3521
+ data: {
3522
+ apiKey: newVipKey,
3523
+ tier: 'Developer VIP Tier',
3524
+ rateLimit: 1000,
3525
+ quota: '1,000 requests / minute',
3526
+ status: 'unlocked',
3527
+ storage: isSupabaseConfigured ? 'supabase-postgresql (persistent)' : 'in-memory (standalone)',
3528
+ achievement: 'Ju Jingyi Miju Master (15/15 Perfect Score 🌸)',
3529
+ usageInstruction: "Attach header 'X-API-Key: <your_key>' or 'Authorization: Bearer <your_key>'"
3530
+ }
3531
+ }, { ...rateHeaders, 'Cache-Control': 'no-store', Pragma: 'no-cache' });
3532
+ }
3533
+
3534
+ if (pathname === '/api/v1/stats') {
3535
+ const cleanSongs = getCleanDiscography();
3536
+ return sendJson(res, 200, {
3537
+ status: 'success',
3538
+ code: 200,
3539
+ timestamp,
3540
+ data: {
3541
+ totalWorks: worksData.length,
3542
+ historicalDramas: worksData.filter(w => w.category === 'historical-drama').length,
3543
+ modernDramas: worksData.filter(w => w.category === 'modern-drama').length,
3544
+ movies: worksData.filter(w => w.category === 'movie').length,
3545
+ totalSongs: cleanSongs.length,
3546
+ latest2026Releases: cleanSongs.filter(s => s.year === 2026).length,
3547
+ ostTracks: discographyData.ostList.length,
3548
+ singles: discographyData.singles.length,
3549
+ officialMusicVideos: musicVideoData.length,
3550
+ brandEndorsements: brandsData.brandList.length,
3551
+ globalBrandAmbassadorCount: brandsData.brandList.filter(b => b.isGlobal).length,
3552
+ careerMilestones: timelineData.length,
3553
+ honorsAndAwards: awardsData.length,
3554
+ galleryPhotos: galleryData.length,
3555
+ socialFollowers: "21.9M+ (Douyin) & 20M+ (Weibo)"
3556
+ }
3557
+ }, rateHeaders);
3558
+ }
3559
+
3560
+ if (pathname === '/api/v1/profile') {
3561
+ return sendJson(res, 200, {
3562
+ status: 'success',
3563
+ code: 200,
3564
+ timestamp,
3565
+ data: profileData
3566
+ }, rateHeaders);
3567
+ }
3568
+
3569
+ if (pathname === '/api/v1/fandom') {
3570
+ return sendJson(res, 200, {
3571
+ status: 'success',
3572
+ code: 200,
3573
+ timestamp,
3574
+ data: {
3575
+ fandomNameTh: "มี่จวี (Miju)",
3576
+ fandomNameCn: "蜜橘",
3577
+ fandomColors: [
3578
+ { nameTh: "ฟ้าคริสตัล (Crystal Sky Blue)", hex: "#58B2DC" },
3579
+ { nameTh: "ชมพูซากุระ (Sakura Pink)", hex: "#FAD2E1" },
3580
+ { nameTh: "ทองหรูหรา (Imperial Gold)", hex: "#D4AF37" }
3581
+ ],
3582
+ sloganTh: "保持热爱,奔赴山海 (รักษาความรักและมุ่งหน้าไปสู่ขุนเขาและท้องทะเล)",
3583
+ officialAnniversary: "2 พฤศจิกายน 2013 (SNH48 Debut)",
3584
+ supportGuidelines: "สนับสนุนผลงานอย่างสร้างสรรค์ เคารพสิทธิส่วนบุคคล และร่วมทำกิจกรรมการกุศลในนามศิลปิน"
3585
+ }
3586
+ }, rateHeaders);
3587
+ }
3588
+
3589
+ if (pathname === '/api/v1/quotes') {
3590
+ const quotes = [
3591
+ {
3592
+ quoteOriginal: "过去的事情,就让它过去吧,我们要往前看。",
3593
+ quoteTh: "เรื่องที่ผ่านไปแล้ว ก็ปล่อยให้มันผ่านไป สิ่งที่เราต้องทำคือมองไปข้างหน้า",
3594
+ quoteEn: "Let what has passed remain in the past; what we must do is look forward.",
3595
+ context: "ปรัชญาการทำงานและการก้าวผ่านอุปสรรค"
3596
+ },
3597
+ {
3598
+ quoteOriginal: "只要全力以赴,就不会留下遗憾。",
3599
+ quoteTh: "ตราบใดที่เราทุ่มเทจนสุดความสามารถ ก็จะไม่มีคำว่าเสียใจภายหลัง",
3600
+ quoteEn: "As long as you give it your all, there will be no regrets.",
3601
+ context: "การเตรียมตัวสำหรับการแสดงและเวทีคอนเสิร์ต"
3602
+ },
3603
+ {
3604
+ quoteOriginal: "音乐和演戏是我生命中不可或缺的一部分。",
3605
+ quoteTh: "ดนตรีและการแสดง คือส่วนหนึ่งของชีวิตที่ไม่สามารถขาดไปได้",
3606
+ quoteEn: "Music and acting are indispensable parts of my life.",
3607
+ context: "ความหลงใหลในศิลปะการแสดง"
3608
+ }
3609
+ ];
3610
+
3611
+ return sendJson(res, 200, {
3612
+ status: 'success',
3613
+ code: 200,
3614
+ timestamp,
3615
+ data: quotes,
3616
+ meta: { total: quotes.length }
3617
+ }, rateHeaders);
3618
+ }
3619
+
3620
+ if (pathname === '/api/v1/character-roles') {
3621
+ const characterRoles = worksData.map(w => ({
3622
+ workId: w.id,
3623
+ workTitleTh: w.titleTh,
3624
+ workTitleCn: w.titleCn,
3625
+ workYear: w.year,
3626
+ characterName: w.role,
3627
+ characterType: w.roleType,
3628
+ characterDescription: w.roleDescription,
3629
+ dramaCategory: w.category,
3630
+ poster: w.poster
3631
+ }));
3632
+
3633
+ return sendJson(res, 200, {
3634
+ status: 'success',
3635
+ code: 200,
3636
+ timestamp,
3637
+ data: characterRoles,
3638
+ meta: { total: characterRoles.length }
3639
+ }, rateHeaders);
3640
+ }
3641
+
3642
+ if (pathname === '/api/v1/timeline') {
3643
+ const year = sanitizeSearchQuery(searchParams.get('year') || '', 20);
3644
+ const era = sanitizeSearchQuery(searchParams.get('era') || '', 50).toLowerCase();
3645
+ const category = sanitizeSearchQuery(searchParams.get('category') || '', 50).toLowerCase();
3646
+ const search = sanitizeSearchQuery(searchParams.get('search') || searchParams.get('q') || '', 100).toLowerCase();
3647
+
3648
+ let list = [...timelineData];
3649
+ if (year && year !== 'all') {
3650
+ list = list.filter(t => t.year === year || String(t.year).includes(year));
3651
+ }
3652
+ if (era && era !== 'all') {
3653
+ list = list.filter(t => t.era && t.era.toLowerCase().includes(era));
3654
+ }
3655
+ if (category && category !== 'all') {
3656
+ list = list.filter(t => t.category && t.category.toLowerCase().includes(category));
3657
+ }
3658
+ if (search) {
3659
+ list = list.filter(t =>
3660
+ (t.title && t.title.toLowerCase().includes(search)) ||
3661
+ (t.titleCn && t.titleCn.includes(search)) ||
3662
+ (t.summary && t.summary.toLowerCase().includes(search)) ||
3663
+ (t.badge && t.badge.toLowerCase().includes(search))
3664
+ );
3665
+ }
3666
+
3667
+ return sendJson(res, 200, {
3668
+ status: 'success',
3669
+ code: 200,
3670
+ timestamp,
3671
+ data: list,
3672
+ meta: {
3673
+ totalMilestones: timelineData.length,
3674
+ filteredCount: list.length
3675
+ }
3676
+ }, rateHeaders);
3677
+ }
3678
+
3679
+ if (pathname === '/api/v1/agent/query' || pathname === '/api/v1/chat') {
3680
+ if (!hasValidApiKey || !hasAgentExecutionAccess) {
3681
+ return sendJson(res, 401, {
3682
+ status: 'error', code: 401, timestamp,
3683
+ message: 'An agent-execution API key is required for model execution.'
3684
+ }, rateHeaders);
3685
+ }
3686
+ const body = req.method === 'POST' ? await parseJsonBody(req) : {};
3687
+ const rawQuery = body.query || body.prompt || searchParams.get('q') || searchParams.get('query') || '';
3688
+ const query = sanitizeSearchQuery(rawQuery, 2000);
3689
+ if (!query) {
3690
+ return sendJson(res, 400, {
3691
+ status: 'error',
3692
+ code: 400,
3693
+ timestamp,
3694
+ message: "Missing 'query' or 'q' field."
3695
+ }, rateHeaders);
3696
+ }
3697
+
3698
+ try {
3699
+ const { getSharedAgent } = await import('./routes/agent.js');
3700
+ const provider = sanitizeSearchQuery(body.provider || 'lmstudio', 40).toLowerCase();
3701
+ const providerApiKey = extractProviderApiKey(req);
3702
+ if (provider !== 'lmstudio' && !providerApiKey) {
3703
+ return sendJson(res, 401, {
3704
+ status: 'error', code: 401, timestamp,
3705
+ message: 'X-Provider-API-Key is required for cloud model providers.'
3706
+ }, rateHeaders);
3707
+ }
3708
+ const agent = getSharedAgent({
3709
+ provider,
3710
+ model: sanitizeSearchQuery(body.model || '', 160) || undefined,
3711
+ apiKey: providerApiKey || undefined,
3712
+ });
3713
+ await agent.ready();
3714
+ let collectedAnswer = '';
3715
+ await agent.ask(query, {
3716
+ onFinalAnswer: (answer) => { collectedAnswer = answer; }
3717
+ });
3718
+ return sendJson(res, 200, {
3719
+ status: 'success',
3720
+ code: 200,
3721
+ timestamp,
3722
+ query,
3723
+ answer: collectedAnswer || 'No response generated.'
3724
+ }, rateHeaders);
3725
+ } catch (err) {
3726
+ console.error('[Agent API Error]:', err);
3727
+ return sendJson(res, 500, {
3728
+ status: 'error',
3729
+ code: 500,
3730
+ timestamp,
3731
+ message: safeErrorMessage(err, 'Agent execution failed')
3732
+ }, rateHeaders);
3733
+ }
3734
+ }
3735
+
3736
+ if (pathname === '/api/v1/agent/chat' || pathname === '/api/v1/chat/stream') {
3737
+ if (!hasValidApiKey || !hasAgentExecutionAccess) {
3738
+ return sendJson(res, 401, {
3739
+ status: 'error', code: 401, timestamp,
3740
+ message: 'An agent-execution API key is required for model execution.'
3741
+ }, rateHeaders);
3742
+ }
3743
+ const rawQ = searchParams.get('q') || searchParams.get('query') || '';
3744
+ const q = sanitizeSearchQuery(rawQ, 2000);
3745
+ if (!q) {
3746
+ return sendJson(res, 400, {
3747
+ status: 'error',
3748
+ code: 400,
3749
+ timestamp,
3750
+ message: "Missing 'q' or 'query' parameter for agent chat stream."
3751
+ }, rateHeaders);
3752
+ }
3753
+ const provider = sanitizeSearchQuery(searchParams.get('provider') || 'lmstudio', 40).toLowerCase();
3754
+ const providerApiKey = extractProviderApiKey(req);
3755
+ if (provider !== 'lmstudio' && !providerApiKey) {
3756
+ return sendJson(res, 401, {
3757
+ status: 'error', code: 401, timestamp,
3758
+ message: 'X-Provider-API-Key is required for cloud model providers.'
3759
+ }, rateHeaders);
3760
+ }
3761
+ const { handleAgentChatStream } = await import('./routes/agent.js');
3762
+ return handleAgentChatStream(req, res, q, {
3763
+ provider,
3764
+ model: sanitizeSearchQuery(searchParams.get('model') || '', 160) || undefined,
3765
+ apiKey: providerApiKey || undefined,
3766
+ });
3767
+ }
3768
+
3769
+ if (pathname === '/api/v1/debate') {
3770
+ if (!hasValidApiKey || !hasAgentExecutionAccess) {
3771
+ return sendJson(res, 401, {
3772
+ status: 'error', code: 401, timestamp,
3773
+ message: 'An agent-execution API key is required for debate execution.'
3774
+ }, rateHeaders);
3775
+ }
3776
+ const body = req.method === 'POST' ? await parseJsonBody(req) : {};
3777
+ const rawTopic = body.topic || body.claim || searchParams.get('topic') || searchParams.get('claim') || searchParams.get('q') || '';
3778
+ const topic = sanitizeSearchQuery(rawTopic, 500);
3779
+ if (!topic) {
3780
+ return sendJson(res, 400, {
3781
+ status: 'error',
3782
+ code: 400,
3783
+ timestamp,
3784
+ message: "Missing 'topic' or 'claim' parameter for debate."
3785
+ }, rateHeaders);
3786
+ }
3787
+
3788
+ try {
3789
+ const { DebateOrchestrator } = await import('./agent/debate.js');
3790
+ const { getSharedAgent } = await import('./routes/agent.js');
3791
+ const agent = getSharedAgent();
3792
+ const orchestrator = new DebateOrchestrator({
3793
+ client: agent.client || agent.llmClient,
3794
+ executeTool: (name, args) => agent.executeTool(name, args)
3795
+ });
3796
+ const debateResult = await orchestrator.run(topic);
3797
+ return sendJson(res, 200, {
3798
+ status: 'success',
3799
+ code: 200,
3800
+ timestamp,
3801
+ data: debateResult
3802
+ }, rateHeaders);
3803
+ } catch (err) {
3804
+ console.error('[Debate API Error]:', err);
3805
+ return sendJson(res, 500, {
3806
+ status: 'error',
3807
+ code: 500,
3808
+ timestamp,
3809
+ message: safeErrorMessage(err, 'Debate execution failed')
3810
+ }, rateHeaders);
3811
+ }
3812
+ }
3813
+
3814
+ if (pathname === '/api/v1/works' || pathname === '/api/v1/dramas') {
3815
+ let results = [...worksData];
3816
+ const category = sanitizeSearchQuery(searchParams.get('category') || '', 50);
3817
+ const year = sanitizeSearchQuery(searchParams.get('year') || '', 20);
3818
+ const search = sanitizeSearchQuery(searchParams.get('search') || '', 100);
3819
+
3820
+ if (category && category !== 'all') {
3821
+ results = results.filter(w => w.category === category);
3822
+ }
3823
+ if (year) {
3824
+ results = results.filter(w => String(w.year) === String(year));
3825
+ }
3826
+ if (search) {
3827
+ const q = search.toLowerCase();
3828
+ results = results.filter(w =>
3829
+ w.titleTh.toLowerCase().includes(q) ||
3830
+ w.titleCn.includes(q) ||
3831
+ w.titleEn.toLowerCase().includes(q) ||
3832
+ w.role.toLowerCase().includes(q)
3833
+ );
3834
+ }
3835
+
3836
+ return sendJson(res, 200, {
3837
+ status: 'success',
3838
+ code: 200,
3839
+ timestamp,
3840
+ data: results,
3841
+ meta: {
3842
+ total: worksData.length,
3843
+ count: results.length
3844
+ }
3845
+ }, rateHeaders);
3846
+ }
3847
+
3848
+ if (pathname.startsWith('/api/v1/works/') || pathname.startsWith('/api/v1/dramas/')) {
3849
+ const rawId = pathname.replace(/^\/api\/v1\/(works|dramas)\//, '').trim();
3850
+ const id = safeDecode(rawId);
3851
+ const work = worksData.find(w => String(w.id) === id || String(w.id) === rawId || w.titleCn === id);
3852
+
3853
+ if (!work) {
3854
+ return sendJson(res, 404, {
3855
+ status: 'error',
3856
+ code: 404,
3857
+ timestamp,
3858
+ message: `Work with identifier '${id}' was not found.`
3859
+ }, rateHeaders);
3860
+ }
3861
+
3862
+ return sendJson(res, 200, {
3863
+ status: 'success',
3864
+ code: 200,
3865
+ timestamp,
3866
+ data: work
3867
+ }, rateHeaders);
3868
+ }
3869
+
3870
+ if (pathname === '/api/v1/discography') {
3871
+ let cleanSongs = getCleanDiscography();
3872
+ const type = sanitizeSearchQuery(searchParams.get('type') || '', 50);
3873
+ const year = sanitizeSearchQuery(searchParams.get('year') || '', 20);
3874
+ const search = sanitizeSearchQuery(searchParams.get('search') || '', 100);
3875
+
3876
+ if (type === 'latest') {
3877
+ cleanSongs = cleanSongs.filter(s => s.catalogTypes.includes('latest'));
3878
+ } else if (type === 'ost') {
3879
+ cleanSongs = cleanSongs.filter(s => s.catalogTypes.includes('ost'));
3880
+ } else if (type === 'singles') {
3881
+ cleanSongs = cleanSongs.filter(s => s.catalogTypes.includes('singles'));
3882
+ }
3883
+
3884
+ if (year) {
3885
+ cleanSongs = cleanSongs.filter(s => String(s.year) === String(year));
3886
+ }
3887
+
3888
+ if (search) {
3889
+ const q = search.toLowerCase();
3890
+ cleanSongs = cleanSongs.filter(s =>
3891
+ (s.titleTh && s.titleTh.toLowerCase().includes(q)) ||
3892
+ (s.titleCn && s.titleCn.includes(q)) ||
3893
+ (s.titleEn && s.titleEn.toLowerCase().includes(q))
3894
+ );
3895
+ }
3896
+
3897
+ return sendJson(res, 200, {
3898
+ status: 'success',
3899
+ code: 200,
3900
+ timestamp,
3901
+ data: cleanSongs,
3902
+ meta: {
3903
+ totalUniqueTracks: getCleanDiscography().length,
3904
+ count: cleanSongs.length
3905
+ }
3906
+ }, rateHeaders);
3907
+ }
3908
+
3909
+ if (pathname.startsWith('/api/v1/discography/')) {
3910
+ const rawId = pathname.replace('/api/v1/discography/', '').trim();
3911
+ const id = safeDecode(rawId);
3912
+ const cleanSongs = getCleanDiscography();
3913
+ const song = cleanSongs.find(s =>
3914
+ String(s.id) === id ||
3915
+ String(s.id) === rawId ||
3916
+ String(s.id).replace(/^(single|ost)-/, '') === id ||
3917
+ String(s.id).replace(/^(single|ost)-/, '') === rawId ||
3918
+ s.titleCn === id ||
3919
+ (s.titleEn && s.titleEn.toLowerCase().replace(/\s+/g, '-') === id.toLowerCase()) ||
3920
+ (s.titleTh && s.titleTh.toLowerCase().replace(/\s+/g, '-') === id.toLowerCase())
3921
+ );
3922
+
3923
+ if (!song) {
3924
+ return sendJson(res, 404, {
3925
+ status: 'error',
3926
+ code: 404,
3927
+ timestamp,
3928
+ message: `Track with identifier '${id}' was not found in discography.`
3929
+ }, rateHeaders);
3930
+ }
3931
+
3932
+ return sendJson(res, 200, {
3933
+ status: 'success',
3934
+ code: 200,
3935
+ timestamp,
3936
+ data: song
3937
+ }, rateHeaders);
3938
+ }
3939
+
3940
+ if (pathname === '/api/v1/mv') {
3941
+ let results = [...musicVideoData];
3942
+ const category = sanitizeSearchQuery(searchParams.get('category') || '', 50);
3943
+
3944
+ if (category && category !== 'all') {
3945
+ results = results.filter(v => v.category === category);
3946
+ }
3947
+
3948
+ return sendJson(res, 200, {
3949
+ status: 'success',
3950
+ code: 200,
3951
+ timestamp,
3952
+ data: results,
3953
+ meta: {
3954
+ total: musicVideoData.length,
3955
+ count: results.length
3956
+ }
3957
+ }, rateHeaders);
3958
+ }
3959
+
3960
+ if (pathname === '/api/v1/brands') {
3961
+ let results = [...brandsData.brandList];
3962
+ const isGlobal = searchParams.get('isGlobal');
3963
+
3964
+ if (isGlobal !== null && isGlobal !== undefined) {
3965
+ const globalBool = isGlobal === 'true';
3966
+ results = results.filter(b => b.isGlobal === globalBool);
3967
+ }
3968
+
3969
+ return sendJson(res, 200, {
3970
+ status: 'success',
3971
+ code: 200,
3972
+ timestamp,
3973
+ data: results,
3974
+ meta: {
3975
+ total: brandsData.brandList.length,
3976
+ count: results.length,
3977
+ summary: brandsData.commercialHighlights
3978
+ }
3979
+ }, rateHeaders);
3980
+ }
3981
+
3982
+ if (pathname === '/api/v1/gallery') {
3983
+ let results = [...galleryData];
3984
+ const category = sanitizeSearchQuery(searchParams.get('category') || '', 50);
3985
+ const limit = parseInt(searchParams.get('limit') || '0', 10);
3986
+
3987
+ if (category && category !== 'all') {
3988
+ results = results.filter(g => g.category === category);
3989
+ }
3990
+ if (limit > 0) {
3991
+ results = results.slice(0, limit);
3992
+ }
3993
+
3994
+ return sendJson(res, 200, {
3995
+ status: 'success',
3996
+ code: 200,
3997
+ timestamp,
3998
+ data: results,
3999
+ meta: {
4000
+ total: galleryData.length,
4001
+ count: results.length
4002
+ }
4003
+ }, rateHeaders);
4004
+ }
4005
+
4006
+ if (pathname === '/api/v1/awards') {
4007
+ return sendJson(res, 200, {
4008
+ status: 'success',
4009
+ code: 200,
4010
+ timestamp,
4011
+ data: awardsData,
4012
+ meta: {
4013
+ total: awardsData.length
4014
+ }
4015
+ }, rateHeaders);
4016
+ }
4017
+
4018
+ if (pathname === '/api/v1/social/douyin/embed') {
4019
+ const rawItemId = searchParams.get('itemId') || searchParams.get('id') || '7384920194819283921';
4020
+ const itemId = sanitizeSearchQuery(rawItemId, 50);
4021
+ const iframeHtml = `<iframe src="https://www.iesdouyin.com/share/video/${escapeHtml(itemId)}/" width="100%" height="100%" frameborder="0" allowfullscreen="true" allow="autoplay; fullscreen"></iframe>`;
4022
+ return sendJson(res, 200, {
4023
+ status: 'success',
4024
+ code: 200,
4025
+ timestamp,
4026
+ data: {
4027
+ platform: 'Douyin (抖音)',
4028
+ itemId,
4029
+ iframeSrc: `https://www.iesdouyin.com/share/video/${itemId}/`,
4030
+ iframeHtml,
4031
+ shareUrl: `https://www.iesdouyin.com/share/video/${itemId}/`,
4032
+ openApiStandard: {
4033
+ docUrl: 'https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/openapi/video-management/douyin/iframe-player/get-iframe-by-video',
4034
+ endpoint: 'https://open.douyin.com/api/douyin/v1/video/get_iframe_by_video'
4035
+ }
4036
+ }
4037
+ }, rateHeaders);
4038
+ }
4039
+
4040
+ if (pathname === '/api/v1/social/weibo/live') {
4041
+ return sendJson(res, 200, {
4042
+ status: 'success',
4043
+ code: 200,
4044
+ timestamp,
4045
+ data: {
4046
+ platform: 'Sina Weibo (新浪微博)',
4047
+ account: '鞠婧祎 (Ju Jingyi)',
4048
+ uid: '3669102477',
4049
+ containerId: '1076033669102477',
4050
+ status: 'synced',
4051
+ feedEndpoint: 'https://m.weibo.cn/api/container/getIndex?type=uid&value=3669102477&containerid=1076033669102477',
4052
+ cards: socialUpdatesData.weibo.cards,
4053
+ openApiStandard: {
4054
+ officialDoc: 'https://open.weibo.com/wiki/2/statuses/user_timeline',
4055
+ officialEndpoint: 'https://api.weibo.com/2/statuses/user_timeline.json',
4056
+ cdnHosts: ['https://sinaimg.cn', 'https://wx1.sinaimg.cn', 'https://wx2.sinaimg.cn', 'https://wx3.sinaimg.cn', 'https://wx4.sinaimg.cn']
4057
+ }
4058
+ },
4059
+ meta: {
4060
+ isLiveSynced: true,
4061
+ source: 'hybrid-live-verified'
4062
+ }
4063
+ }, rateHeaders);
4064
+ }
4065
+
4066
+ if (pathname === '/api/v1/social') {
4067
+ const platform = sanitizeSearchQuery(searchParams.get('platform') || '', 50);
4068
+ const days = parseInt(searchParams.get('days') || '14', 10);
4069
+
4070
+ let results = {
4071
+ douyin: socialUpdatesData.douyin,
4072
+ weibo: socialUpdatesData.weibo
4073
+ };
4074
+
4075
+ if (platform === 'douyin') {
4076
+ results = results.douyin;
4077
+ } else if (platform === 'weibo') {
4078
+ results = results.weibo;
4079
+ }
4080
+
4081
+ const allCards = [
4082
+ ...(results.douyin?.cards || []),
4083
+ ...(results.weibo?.cards || [])
4084
+ ];
4085
+
4086
+ return sendJson(res, 200, {
4087
+ status: 'success',
4088
+ code: 200,
4089
+ timestamp,
4090
+ data: results,
4091
+ meta: {
4092
+ totalCards: allCards.length,
4093
+ douyinCards: results.douyin?.cards?.length || 0,
4094
+ weiboCards: results.weibo?.cards?.length || 0,
4095
+ timeWindowDays: days,
4096
+ storageSource: 'manual_data',
4097
+ endpoints: {
4098
+ douyinEmbed: `${baseUrl}/api/v1/social/douyin/embed?itemId=7384920194819283921`,
4099
+ weiboLive: `${baseUrl}/api/v1/social/weibo/live`
4100
+ }
4101
+ }
4102
+ }, rateHeaders);
4103
+ }
4104
+
4105
+ if (pathname === '/api/v1/search') {
4106
+ const rawQ = searchParams.get('q') || searchParams.get('query') || '';
4107
+ const q = sanitizeSearchQuery(rawQ, 100).toLowerCase();
4108
+ if (!q) {
4109
+ return sendJson(res, 400, {
4110
+ status: 'error',
4111
+ code: 400,
4112
+ timestamp,
4113
+ message: "Missing query parameter 'q'. Example: /api/v1/search?q=In+Blossom"
4114
+ }, rateHeaders);
4115
+ }
4116
+
4117
+ const matchedWorks = worksData.filter(w =>
4118
+ (w.titleTh && w.titleTh.toLowerCase().includes(q)) ||
4119
+ (w.titleCn && w.titleCn.includes(q)) ||
4120
+ (w.titleEn && w.titleEn.toLowerCase().includes(q)) ||
4121
+ (w.role && w.role.toLowerCase().includes(q)) ||
4122
+ (w.year && String(w.year).includes(q))
4123
+ );
4124
+
4125
+ const cleanSongs = getCleanDiscography();
4126
+ const matchedSongs = cleanSongs.filter(s =>
4127
+ (s.titleTh && s.titleTh.toLowerCase().includes(q)) ||
4128
+ (s.titleCn && s.titleCn.includes(q)) ||
4129
+ (s.titleEn && s.titleEn.toLowerCase().includes(q)) ||
4130
+ (s.work && s.work.toLowerCase().includes(q)) ||
4131
+ (s.year && String(s.year).includes(q))
4132
+ );
4133
+
4134
+ const matchedVideos = musicVideoData.filter(v =>
4135
+ (v.titleTh && v.titleTh.toLowerCase().includes(q)) ||
4136
+ (v.titleCn && v.titleCn.includes(q)) ||
4137
+ (v.titleEn && v.titleEn.toLowerCase().includes(q)) ||
4138
+ (v.description && v.description.toLowerCase().includes(q))
4139
+ );
4140
+
4141
+ const matchedTimeline = timelineData.filter(t =>
4142
+ (t.title && t.title.toLowerCase().includes(q)) ||
4143
+ (t.titleCn && t.titleCn.includes(q)) ||
4144
+ (t.summary && t.summary.toLowerCase().includes(q)) ||
4145
+ (t.year && String(t.year).includes(q))
4146
+ );
4147
+
4148
+ return sendJson(res, 200, {
4149
+ status: 'success',
4150
+ code: 200,
4151
+ timestamp,
4152
+ data: {
4153
+ works: matchedWorks,
4154
+ discography: matchedSongs,
4155
+ musicVideos: matchedVideos,
4156
+ timeline: matchedTimeline
4157
+ },
4158
+ meta: {
4159
+ query: q,
4160
+ totalMatches: matchedWorks.length + matchedSongs.length + matchedVideos.length + matchedTimeline.length
4161
+ }
4162
+ }, rateHeaders);
4163
+ }
4164
+
4165
+ if (pathname === '/mcp') {
4166
+ if (req.method === 'GET') {
4167
+ const { getMcpSessionStats } = await import('./mcp/handler.js');
4168
+ const { toolDefinitions } = await import('./mcp/tools.js');
4169
+ const { resourceDefinitions } = await import('./mcp/resources.js');
4170
+ const { promptDefinitions } = await import('./mcp/prompts.js');
4171
+ return sendJson(res, 200, {
4172
+ status: 'success',
4173
+ code: 200,
4174
+ timestamp,
4175
+ name: 'kiku',
4176
+ title: 'kiku',
4177
+ version: PACKAGE_VERSION,
4178
+ protocol: 'mcp',
4179
+ transport: 'streamable-http',
4180
+ endpoints: {
4181
+ http: `${baseUrl}/mcp`
4182
+ },
4183
+ capabilities: {
4184
+ toolsCount: toolDefinitions.length,
4185
+ resourcesCount: resourceDefinitions.length,
4186
+ promptsCount: promptDefinitions.length
4187
+ },
4188
+ stats: getMcpSessionStats(),
4189
+ documentation: 'https://jujingyi-kikuuu.vercel.app/api-docs'
4190
+ }, rateHeaders);
4191
+ }
4192
+
4193
+ try {
4194
+ const { handleMcpHttp } = await import('./mcp/handler.js');
4195
+ await handleMcpHttp(req, res);
4196
+ return;
4197
+ } catch (err) {
4198
+ console.error('[MCP HTTP Error]:', err);
4199
+ return sendJson(res, 500, {
4200
+ status: 'error', code: 500, timestamp,
4201
+ message: safeErrorMessage(err, 'MCP Streamable HTTP error')
4202
+ }, rateHeaders);
4203
+ }
4204
+ }
4205
+
4206
+ if (pathname === '/mcp/sse' || pathname === '/mcp/message') {
4207
+ return sendJson(res, 410, {
4208
+ status: 'error',
4209
+ code: 410,
4210
+ timestamp,
4211
+ error: 'Legacy SSE transport removed. Use POST /mcp.'
4212
+ }, rateHeaders);
4213
+ }
4214
+
4215
+
4216
+ return sendJson(res, 404, {
4217
+ status: 'error',
4218
+ code: 404,
4219
+ timestamp,
4220
+ message: `API endpoint '${pathname}' does not exist on API v1. Check /api-docs or /api/v1/health for available routes.`
4221
+ }, rateHeaders);
4222
+ }