four-flap-meme-sdk 1.8.4 → 1.8.6

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.
@@ -7,6 +7,13 @@ export type FourConfig = {
7
7
  * 如需自定义,可传入自己的代理地址
8
8
  */
9
9
  baseUrl?: string;
10
+ /**
11
+ * 图片上传备用代理 URL 列表
12
+ *
13
+ * 当主 baseUrl 触发 IP 限流(code -1038)时,自动依次尝试备用端点。
14
+ * 每个 URL 格式同 baseUrl,例如 'https://bscfourapi2.emit.tools'
15
+ */
16
+ uploadFallbackUrls?: string[];
10
17
  };
11
18
  export type GenerateNonceReq = {
12
19
  accountAddress: string;
@@ -90,10 +97,31 @@ export type CreateTokenResp = {
90
97
  };
91
98
  export declare class FourClient {
92
99
  private baseUrl;
100
+ private uploadUrls;
93
101
  constructor(cfg?: FourConfig);
94
102
  generateNonce(req: GenerateNonceReq): Promise<string>;
95
103
  loginDex(body: LoginReq): Promise<string>;
104
+ /**
105
+ * 上传图片到 Four.meme
106
+ *
107
+ * 优化策略:
108
+ * 1. 图片内容哈希缓存 —— 相同图片不重复上传,直接返回缓存 URL
109
+ * 2. 多端点轮换 —— 当某个代理 IP 触发限流(-1038)时自动切到下一个端点
110
+ */
96
111
  uploadImage(accessToken: string, file: Blob, filename?: string): Promise<string>;
112
+ /**
113
+ * 执行单次上传请求
114
+ */
115
+ private doUpload;
116
+ /**
117
+ * 判断是否为 IP 限流错误(code: -1038)
118
+ */
119
+ private isRateLimitError;
120
+ /**
121
+ * 判断是否为 CORS 或网络错误
122
+ * 浏览器直连官方 API 时常见(fetch 会抛出 TypeError: Failed to fetch)
123
+ */
124
+ private isCorsOrNetworkError;
97
125
  private getFilenameFromBlob;
98
126
  createToken(accessToken: string, req: CreateTokenReq): Promise<CreateTokenResp>;
99
127
  getPublicConfig(): Promise<any>;
@@ -1,7 +1,102 @@
1
+ // ============================================================================
2
+ // 图片上传缓存(按内容哈希去重,同一张图片只上传一次)
3
+ // ✅ 使用 localStorage 持久化,刷新页面 / 重启浏览器后缓存仍然有效
4
+ // ============================================================================
5
+ const UPLOAD_CACHE_KEY = '__four_upload_cache__';
6
+ const UPLOAD_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 天过期
7
+ /** 内存缓存(从 localStorage 懒加载) */
8
+ let _memCache = null;
9
+ /** 加载持久化缓存到内存 */
10
+ function loadCache() {
11
+ if (_memCache)
12
+ return _memCache;
13
+ _memCache = new Map();
14
+ try {
15
+ if (typeof localStorage === 'undefined')
16
+ return _memCache;
17
+ const raw = localStorage.getItem(UPLOAD_CACHE_KEY);
18
+ if (raw) {
19
+ const entries = JSON.parse(raw);
20
+ const now = Date.now();
21
+ for (const [hash, entry] of Object.entries(entries)) {
22
+ // 跳过过期条目
23
+ if (now - entry.ts < UPLOAD_CACHE_MAX_AGE_MS) {
24
+ _memCache.set(hash, entry);
25
+ }
26
+ }
27
+ }
28
+ }
29
+ catch {
30
+ // localStorage 不可用或数据损坏,忽略
31
+ }
32
+ return _memCache;
33
+ }
34
+ /** 保存内存缓存到 localStorage */
35
+ function saveCache() {
36
+ try {
37
+ if (typeof localStorage === 'undefined')
38
+ return;
39
+ const cache = loadCache();
40
+ const obj = {};
41
+ cache.forEach((v, k) => { obj[k] = v; });
42
+ localStorage.setItem(UPLOAD_CACHE_KEY, JSON.stringify(obj));
43
+ }
44
+ catch {
45
+ // localStorage 写满或不可用,忽略
46
+ }
47
+ }
48
+ /** 从缓存获取 URL */
49
+ function getCachedUrl(hash) {
50
+ const cache = loadCache();
51
+ const entry = cache.get(hash);
52
+ if (!entry)
53
+ return null;
54
+ if (Date.now() - entry.ts > UPLOAD_CACHE_MAX_AGE_MS) {
55
+ cache.delete(hash);
56
+ saveCache();
57
+ return null;
58
+ }
59
+ return entry.url;
60
+ }
61
+ /** 写入缓存 */
62
+ function setCachedUrl(hash, url) {
63
+ const cache = loadCache();
64
+ cache.set(hash, { url, ts: Date.now() });
65
+ saveCache();
66
+ }
67
+ /**
68
+ * 计算 Blob 的 SHA-256 哈希(十六进制)
69
+ * 用于图片去重,避免重复上传消耗 IP 配额
70
+ */
71
+ async function hashBlob(blob) {
72
+ const buffer = await blob.arrayBuffer();
73
+ const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
74
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
75
+ return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
76
+ }
77
+ /** IP 限流错误码 */
78
+ const RATE_LIMIT_CODE = -1038;
79
+ /**
80
+ * Four.meme 官方 API 地址(作为内置备用端点)
81
+ *
82
+ * - 在 Node.js/服务端环境中:无 CORS 限制,直连走用户自己的 IP,不消耗 Worker 配额
83
+ * - 在浏览器环境中:可能被 CORS 拦截,但作为最后的 fallback 仍值得尝试
84
+ */
85
+ const FOUR_MEME_OFFICIAL_API = 'https://four.meme/meme-api';
1
86
  export class FourClient {
2
87
  constructor(cfg) {
3
88
  // 默认使用 Cloudflare Workers 代理,已配置 CORS,浏览器可直接使用
4
89
  this.baseUrl = cfg?.baseUrl || 'https://bscfourapi.emit.tools';
90
+ // 上传端点列表:主 URL + 用户自定义备用 URL + 官方 API(兜底)
91
+ const fallbacks = cfg?.uploadFallbackUrls || [];
92
+ this.uploadUrls = [
93
+ this.baseUrl,
94
+ ...fallbacks,
95
+ // ✅ 官方 API 始终作为最后兜底(Node.js 环境无 CORS 限制,走用户自己 IP)
96
+ FOUR_MEME_OFFICIAL_API,
97
+ ];
98
+ // 去重(如果用户 baseUrl 就是官方地址,避免重复请求)
99
+ this.uploadUrls = [...new Set(this.uploadUrls)];
5
100
  }
6
101
  async generateNonce(req) {
7
102
  const r = await fetch(`${this.baseUrl}/v1/private/user/nonce/generate`, {
@@ -28,22 +123,104 @@ export class FourClient {
28
123
  }
29
124
  return j.data; // access_token
30
125
  }
126
+ /**
127
+ * 上传图片到 Four.meme
128
+ *
129
+ * 优化策略:
130
+ * 1. 图片内容哈希缓存 —— 相同图片不重复上传,直接返回缓存 URL
131
+ * 2. 多端点轮换 —— 当某个代理 IP 触发限流(-1038)时自动切到下一个端点
132
+ */
31
133
  async uploadImage(accessToken, file, filename) {
32
- const form = new FormData();
33
- // 如果提供了文件名,使用它;否则根据 MIME 类型生成
134
+ // 先查缓存(localStorage 持久化):相同图片直接返回,不消耗上传配额
135
+ const hash = await hashBlob(file);
136
+ const cached = getCachedUrl(hash);
137
+ if (cached) {
138
+ console.log(`[FourClient] 图片命中缓存,跳过上传 (hash: ${hash.slice(0, 12)}...)`);
139
+ return cached;
140
+ }
34
141
  const finalFilename = filename || this.getFilenameFromBlob(file);
35
- form.append('file', file, finalFilename);
36
- const r = await fetch(`${this.baseUrl}/v1/private/token/upload`, {
142
+ let lastError = null;
143
+ // 依次尝试所有上传端点(代理 自定义备用 → 官方直连兜底)
144
+ for (let i = 0; i < this.uploadUrls.length; i++) {
145
+ const baseUrl = this.uploadUrls[i];
146
+ const isOfficial = baseUrl === FOUR_MEME_OFFICIAL_API;
147
+ try {
148
+ const imgUrl = await this.doUpload(baseUrl, accessToken, file, finalFilename);
149
+ // 上传成功,写入持久化缓存(localStorage)
150
+ setCachedUrl(hash, imgUrl);
151
+ if (i > 0) {
152
+ console.log(`[FourClient] 使用${isOfficial ? '官方直连' : '备用'}端点上传成功: ${baseUrl}`);
153
+ }
154
+ return imgUrl;
155
+ }
156
+ catch (e) {
157
+ lastError = e;
158
+ const canRetry = i < this.uploadUrls.length - 1;
159
+ if (this.isRateLimitError(e) && canRetry) {
160
+ // IP 限流 → 切下一个端点
161
+ const nextUrl = this.uploadUrls[i + 1];
162
+ const nextLabel = nextUrl === FOUR_MEME_OFFICIAL_API ? '官方直连' : `备用端点 #${i + 2}`;
163
+ console.warn(`[FourClient] 端点 ${baseUrl} 触发 IP 限流(100次/天),切换到${nextLabel}...`);
164
+ continue;
165
+ }
166
+ if (this.isCorsOrNetworkError(e) && canRetry) {
167
+ // CORS/网络错误(常见于浏览器直连官方 API)→ 跳过,继续尝试下一个
168
+ console.warn(`[FourClient] 端点 ${baseUrl} 请求失败(${isOfficial ? 'CORS限制' : '网络错误'}),跳过`);
169
+ continue;
170
+ }
171
+ // 无法重试,跳出
172
+ break;
173
+ }
174
+ }
175
+ // 所有端点都失败
176
+ const isRateLimit = lastError && this.isRateLimitError(lastError);
177
+ if (isRateLimit) {
178
+ throw new Error(`图片上传失败:所有端点均触发 IP 限流(每IP每天100次)。` +
179
+ `请等待次日重置,或在 FourConfig.uploadFallbackUrls 中添加不同 IP 的备用代理。` +
180
+ `\n原始错误: ${lastError.message}`);
181
+ }
182
+ throw lastError || new Error('upload failed: all endpoints exhausted');
183
+ }
184
+ /**
185
+ * 执行单次上传请求
186
+ */
187
+ async doUpload(baseUrl, accessToken, file, filename) {
188
+ const form = new FormData();
189
+ form.append('file', file, filename);
190
+ const r = await fetch(`${baseUrl}/v1/private/token/upload`, {
37
191
  method: 'POST',
38
192
  headers: { 'meme-web-access': accessToken },
39
193
  body: form,
40
194
  });
41
195
  const j = await r.json();
42
196
  if (j.code !== '0' && j.code !== 0) {
43
- throw new Error(`upload failed: ${JSON.stringify(j)}`);
197
+ const err = new Error(`upload failed: ${JSON.stringify(j)}`);
198
+ err.apiCode = typeof j.code === 'string' ? parseInt(j.code, 10) : j.code;
199
+ throw err;
44
200
  }
45
201
  return j.data; // imgUrl
46
202
  }
203
+ /**
204
+ * 判断是否为 IP 限流错误(code: -1038)
205
+ */
206
+ isRateLimitError(e) {
207
+ if (e?.apiCode === RATE_LIMIT_CODE)
208
+ return true;
209
+ // 兼容:从 message 中检测
210
+ if (typeof e?.message === 'string' && e.message.includes('-1038'))
211
+ return true;
212
+ return false;
213
+ }
214
+ /**
215
+ * 判断是否为 CORS 或网络错误
216
+ * 浏览器直连官方 API 时常见(fetch 会抛出 TypeError: Failed to fetch)
217
+ */
218
+ isCorsOrNetworkError(e) {
219
+ if (e instanceof TypeError)
220
+ return true; // fetch CORS / network failure
221
+ const msg = e?.message?.toLowerCase?.() || '';
222
+ return msg.includes('failed to fetch') || msg.includes('network') || msg.includes('cors');
223
+ }
47
224
  getFilenameFromBlob(blob) {
48
225
  const mimeToExt = {
49
226
  'image/jpeg': 'jpg',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "four-flap-meme-sdk",
3
- "version": "1.8.4",
3
+ "version": "1.8.6",
4
4
  "description": "SDK for Flap bonding curve and four.meme TokenManager",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",