koishi-plugin-share-links-analysis 0.15.3 → 0.15.4

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.
package/README.md CHANGED
@@ -169,7 +169,7 @@ http://127.0.0.1:12001
169
169
  | Netease Music | 歌曲、节目,依赖 NeteaseCloudMusicApi 服务 |
170
170
  | QQ Music | 常见歌曲分享链接 |
171
171
  | Kugou Music | 常见歌曲分享链接 |
172
- | GitHub | 仓库链接基础信息 |
172
+ | GitHub | 仓库、分支/标签、发行版、用户与组织主页 |
173
173
 
174
174
  ## 指令
175
175
 
package/lib/index.js CHANGED
@@ -127,6 +127,58 @@ exports.Config = koishi_1.Schema.intersect([
127
127
  netease_apiUrl: koishi_1.Schema.string().role('link').description('网易云音乐 API (NeteaseCloudMusicApi) 的地址<br>' +
128
128
  '<a href="https://docs-neteasecloudmusicapi.focalors.ltd/#/?id=neteasecloudmusicapienhanced" target="_blank">点击此处查看部署方式</a>').default('http://127.0.0.1:3000'),
129
129
  }).description('网易云音乐设置'),
130
+ koishi_1.Schema.intersect([
131
+ koishi_1.Schema.object({
132
+ githubToken: koishi_1.Schema.string().role('secret').description('GitHub API Token(可选)。留空时匿名请求,速率上限为 60 次/小时;填写后提升到 5000 次/小时,<br>' +
133
+ '同时可以读取私有仓库与私有发行版。<br>' +
134
+ '在 GitHub Settings → Developer settings → Personal access tokens 创建,读取公开数据无需勾选任何权限。').default(''),
135
+ githubCover: koishi_1.Schema.union([
136
+ koishi_1.Schema.const('off').description('不生成封面图'),
137
+ koishi_1.Schema.const('opengraph').description('GitHub 官方社交预览图:1200×600,包含仓库头像、简介、Stars/Fork 等'),
138
+ koishi_1.Schema.const('socialify').description('Socialify 生成:1280×640,主题、字体、纹理与徽标可自定义'),
139
+ ]).role('radio').default('off').description('封面图来源'),
140
+ }),
141
+ koishi_1.Schema.union([
142
+ koishi_1.Schema.object({
143
+ githubCover: koishi_1.Schema.const('socialify').required(),
144
+ socialifyTheme: koishi_1.Schema.union([
145
+ koishi_1.Schema.const('Light').description('浅色'),
146
+ koishi_1.Schema.const('Dark').description('深色'),
147
+ ]).default('Light').description('配色主题'),
148
+ socialifyFont: koishi_1.Schema.union([
149
+ koishi_1.Schema.const('Inter'),
150
+ koishi_1.Schema.const('JetBrains Mono'),
151
+ koishi_1.Schema.const('KoHo'),
152
+ koishi_1.Schema.const('Crimson Pro'),
153
+ koishi_1.Schema.const('Bitter'),
154
+ koishi_1.Schema.const('Roboto'),
155
+ koishi_1.Schema.const('Source Code Pro'),
156
+ koishi_1.Schema.const('IBM Plex Mono'),
157
+ koishi_1.Schema.const('Raleway'),
158
+ koishi_1.Schema.const('Poppins'),
159
+ ]).default('Inter').description('字体'),
160
+ socialifyPattern: koishi_1.Schema.union([
161
+ koishi_1.Schema.const('Plus').description('加号点阵(Socialify 默认)'),
162
+ koishi_1.Schema.const('Signal').description('砖块'),
163
+ koishi_1.Schema.const('Charlie Brown').description('斜纹'),
164
+ koishi_1.Schema.const('Formal Invitation').description('菱形网格'),
165
+ koishi_1.Schema.const('Circuit Board').description('电路板'),
166
+ koishi_1.Schema.const('Overlapping Hexagons').description('六边形'),
167
+ koishi_1.Schema.const('Brick Wall').description('砖墙'),
168
+ koishi_1.Schema.const('Hide').description('无纹理'),
169
+ ]).default('Plus').description('背景纹理'),
170
+ socialifyLanguage: koishi_1.Schema.boolean().default(true).description('显示语言徽标'),
171
+ socialifyOwner: koishi_1.Schema.boolean().default(true).description('显示仓库头像'),
172
+ socialifyName: koishi_1.Schema.boolean().default(true).description('显示仓库名'),
173
+ socialifyDescription: koishi_1.Schema.boolean().default(false).description('显示仓库简介'),
174
+ socialifyStars: koishi_1.Schema.boolean().default(true).description('显示 Stars 徽标'),
175
+ socialifyForks: koishi_1.Schema.boolean().default(true).description('显示 Forks 徽标'),
176
+ socialifyIssues: koishi_1.Schema.boolean().default(false).description('显示 Issues 徽标'),
177
+ socialifyPulls: koishi_1.Schema.boolean().default(false).description('显示 Pull Requests 徽标'),
178
+ }),
179
+ koishi_1.Schema.object({}),
180
+ ]),
181
+ ]).description('GitHub 解析设置'),
130
182
  koishi_1.Schema.object({
131
183
  onebotReadDir: koishi_1.Schema.string().description('OneBot 实现 (如 NapCat) 所在的容器或环境提供的路径前缀。').default("/app/.config/QQ/NapCat/temp"),
132
184
  localDownloadDir: koishi_1.Schema.string().description('与上述路径对应的、Koishi 所在的容器或主机可以访问的路径前缀。').default("/koishi/data/temp"),
@@ -764,7 +816,8 @@ function apply(ctx, config) {
764
816
  .join('\n');
765
817
  return `当前解析器状态:\n${parserList}\nNSFW 内容:${nsfw ? '✅' : '❌'}`.trim();
766
818
  });
767
- cmd.subcommand('.parsers [parser:string] [mode:string]', '查看/管理当前会话的解析器状态', { authority: 1 })
819
+ cmd.subcommand('.parser [parser:string] [mode:string]', '查看/管理当前会话的解析器状态', { authority: 1 })
820
+ .alias('.parsers')
768
821
  .action(async ({ session }, parser, value) => {
769
822
  const scope = session && (0, utils_1.getSettingsScope)(session);
770
823
  if (!scope)
@@ -774,13 +827,14 @@ function apply(ctx, config) {
774
827
  const isValidParser = (name) => core_1.parsers_str.includes(name);
775
828
  if (!isValidParser(parser))
776
829
  return '请输入正确的解析器名称';
777
- if (!value)
778
- return '请输入正确的模式';
779
- const mode = value.trim().toLowerCase() === 'true';
830
+ const mode = (0, utils_1.parseBoolean)(value);
831
+ if (mode === undefined)
832
+ return '请输入正确的模式(如 true/false、enable/disable、on/off)';
780
833
  const data = await ctx.database.get('sla_group_settings', scope);
781
834
  const final_parsers = { ...data[0]?.custom_parsers, ...{ [parser]: mode } };
782
835
  const record = { guildId: scope, custom_parsers: final_parsers };
783
836
  await ctx.database.upsert('sla_group_settings', [record]);
837
+ await session.execute('share');
784
838
  });
785
839
  cmd.subcommand('.nsfw [value:string]', '设置是否允许 NSFW 内容', { authority: 1 })
786
840
  .action(async ({ session }, value) => {
@@ -789,7 +843,9 @@ function apply(ctx, config) {
789
843
  return '当前会话不可用。';
790
844
  if (!await canManageSettings(session))
791
845
  return '权限不足';
792
- const mode = value.trim().toLowerCase() === 'true';
846
+ const mode = (0, utils_1.parseBoolean)(value);
847
+ if (mode === undefined)
848
+ return '请输入正确的值(如 true/false、enable/disable、on/off)';
793
849
  const record = { guildId: scope, nsfw_enabled: mode };
794
850
  await ctx.database.upsert('sla_group_settings', [record]);
795
851
  await session.execute('share');
@@ -802,7 +858,8 @@ function apply(ctx, config) {
802
858
  if (!await canManageSettings(session))
803
859
  return '权限不足';
804
860
  await ctx.database.remove('sla_group_settings', { guildId: scope });
805
- return '已重置为全局默认设置。';
861
+ await session.send('已重置为全局默认设置。');
862
+ await session.execute('share');
806
863
  });
807
864
  cmd.subcommand('.migratecache', '迁移旧解析缓存到新时间线并校验', { authority: 4 })
808
865
  .action(async () => {
@@ -444,7 +444,7 @@ async function processArticle(ctx, config, link, logger) {
444
444
  async function processOpus(ctx, config, link, logger) {
445
445
  const opusId = link.id;
446
446
  // B站新版动态必须传递 timezone_offset,否则部分动态获取为空或报错
447
- const apiUrl = `https://api.bilibili.com/x/polymer/web-dynamic/v1/detail?timezone_offset=-480&id=${opusId}`;
447
+ const apiUrl = `https://api.bilibili.com/x/polymer/web-dynamic/v1/detail?timezone_offset=-480&features=itemOpusStyle&id=${opusId}`;
448
448
  try {
449
449
  // 获取 Cookie
450
450
  const cookie = await (0, utils_1.getCookie)(ctx, config, exports.name);
@@ -2,10 +2,10 @@ import { Context, Session } from 'koishi';
2
2
  import { Link, ParsedInfo, PluginConfig } from '../types';
3
3
  export declare const name = "github";
4
4
  /**
5
- * 在文本中匹配 GitHub 仓库链接
5
+ * 在文本中匹配 GitHub 链接
6
6
  */
7
7
  export declare function match(content: string, ctx: Context, config: PluginConfig): Promise<Link[]>;
8
8
  /**
9
- * 处理单个 GitHub 链接
9
+ * 解析单个 GitHub 链接
10
10
  */
11
11
  export declare function process(ctx: Context, config: PluginConfig, link: Link, session: Session): Promise<ParsedInfo | null>;
@@ -6,125 +6,593 @@ exports.match = match;
6
6
  exports.process = process;
7
7
  const utils_1 = require("../utils");
8
8
  exports.name = "github";
9
- const linkRules = [
10
- {
11
- pattern: /(?:https?:\/\/)?(?:www\.)?github\.com\/([a-zA-Z0-9-]+\/[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*)(?=[.,;!?)]|\s|$)/gi,
12
- type: "repo",
13
- }
14
- ];
15
- // 过滤掉 GitHub 官方的一些非仓库根路径
16
- const IGNORE_OWNERS = [
17
- 'settings', 'pulls', 'issues', 'search', 'notifications',
18
- 'explore', 'topics', 'trending', 'collections', 'events',
19
- 'sponsors', 'features', 'enterprise', 'team', 'pricing',
20
- 'about', 'contact', 'login', 'join'
21
- ];
9
+ const API_BASE = 'https://api.github.com';
10
+ // 列表类链接单次展示的条目数
11
+ const LIST_LIMIT = 10;
12
+ // 发行版正文摘要的最大长度
13
+ const RELEASE_BODY_LIMIT = 300;
14
+ // 匹配消息中的 github.com 链接(左边界排除 api./gist. 等子域,允许 www.)
15
+ const LINK_PATTERN = /(?<![\w.-])(?:www\.)?github\.com\/([^\s<>"'`]+)/gi;
16
+ // GitHub 路径里允许出现的字符,用于在中文标点等字符处截断
17
+ const PATH_CHARS = /[^A-Za-z0-9._~%+\-@/].*$/;
18
+ // github.com 下指向站点功能而非用户/仓库的保留路径
19
+ const RESERVED_PATHS = new Set([
20
+ 'about', 'account', 'apps', 'blog', 'changelog', 'codespaces', 'collections', 'contact',
21
+ 'copilot', 'customer-stories', 'dashboard', 'education', 'enterprise', 'events', 'explore',
22
+ 'features', 'github-copilot', 'join', 'login', 'logout', 'marketplace', 'mobile', 'models',
23
+ 'new', 'nonprofit', 'notifications', 'organizations', 'orgs', 'pricing', 'premium-support',
24
+ 'pulls', 'readme', 'search', 'security', 'sessions', 'settings', 'signup', 'site', 'sitemap',
25
+ 'sponsors', 'status', 'team', 'topics', 'trending', 'users', 'wiki',
26
+ ]);
27
+ function isValidOwner(value) {
28
+ return /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(value);
29
+ }
30
+ function isValidRepo(value) {
31
+ return /^[A-Za-z0-9._-]{1,100}$/.test(value);
32
+ }
33
+ /**
34
+ * 把 github.com/ 之后的路径解析成受支持的链接类型
35
+ */
36
+ function parseGithubPath(segments) {
37
+ if (!segments.length)
38
+ return null;
39
+ // github.com/orgs/<org> 与 github.com/<org> 指向同一个组织主页
40
+ if (segments[0].toLowerCase() === 'orgs') {
41
+ const login = segments[1];
42
+ if (!login || segments.length > 2 || !isValidOwner(login))
43
+ return null;
44
+ return { type: 'org', id: login, url: `https://github.com/${login}` };
45
+ }
46
+ if (segments.length === 1) {
47
+ const login = segments[0];
48
+ if (RESERVED_PATHS.has(login.toLowerCase()) || !isValidOwner(login))
49
+ return null;
50
+ return { type: 'user', id: login, url: `https://github.com/${login}` };
51
+ }
52
+ const owner = segments[0];
53
+ if (RESERVED_PATHS.has(owner.toLowerCase()) || !isValidOwner(owner))
54
+ return null;
55
+ let repo = segments[1];
56
+ if (repo.toLowerCase().endsWith('.git'))
57
+ repo = repo.slice(0, -4);
58
+ if (!isValidRepo(repo) || repo === '.' || repo === '..')
59
+ return null;
60
+ const base = `${owner}/${repo}`;
61
+ const repoUrl = `https://github.com/${base}`;
62
+ if (segments.length === 2) {
63
+ return { type: 'repo', id: base, url: repoUrl };
64
+ }
65
+ const action = segments[2].toLowerCase();
66
+ const rest = segments.slice(3);
67
+ if (action === 'tree' && rest.length > 0) {
68
+ const refPath = rest.join('/');
69
+ return { type: 'tree', id: `${base}/tree/${refPath}`, url: `${repoUrl}/tree/${refPath}` };
70
+ }
71
+ if (action === 'branches' && rest.length === 0) {
72
+ return { type: 'branches', id: `${base}/branches`, url: `${repoUrl}/branches` };
73
+ }
74
+ if (action === 'tags' && rest.length === 0) {
75
+ return { type: 'tags', id: `${base}/tags`, url: `${repoUrl}/tags` };
76
+ }
77
+ if (action === 'releases') {
78
+ if (rest.length === 0) {
79
+ return { type: 'releases', id: `${base}/releases`, url: `${repoUrl}/releases` };
80
+ }
81
+ if (rest.length === 1 && rest[0].toLowerCase() === 'latest') {
82
+ return { type: 'release', id: `${base}/releases/latest`, url: `${repoUrl}/releases/latest` };
83
+ }
84
+ if (rest[0].toLowerCase() === 'tag' && rest.length > 1) {
85
+ const tag = rest.slice(1).join('/');
86
+ return { type: 'release', id: `${base}/releases/tag/${tag}`, url: `${repoUrl}/releases/tag/${tag}` };
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+ /**
92
+ * 去掉查询串、锚点与句末标点,并切分成路径片段
93
+ */
94
+ function normalizePath(raw) {
95
+ const path = raw
96
+ .replace(PATH_CHARS, '')
97
+ .replace(/[.,;:!?]+$/, '')
98
+ .replace(/\/+$/, '');
99
+ return path.split('/').filter(Boolean).map((segment) => {
100
+ try {
101
+ return decodeURIComponent(segment);
102
+ }
103
+ catch {
104
+ return segment;
105
+ }
106
+ });
107
+ }
22
108
  /**
23
- * 在文本中匹配 GitHub 仓库链接
109
+ * 在文本中匹配 GitHub 链接
24
110
  */
25
111
  async function match(content, ctx, config) {
26
112
  const results = [];
27
113
  const seen = new Set();
28
- for (const rule of linkRules) {
29
- let m;
30
- rule.pattern.lastIndex = 0;
31
- while ((m = rule.pattern.exec(content)) !== null) {
32
- const id = m[1];
33
- const [owner] = id.split('/');
34
- if (owner && IGNORE_OWNERS.includes(owner.toLowerCase())) {
35
- continue;
36
- }
37
- if (!seen.has(id)) {
38
- seen.add(id);
39
- results.push({
40
- platform: exports.name,
41
- type: rule.type,
42
- id: id,
43
- url: `https://github.com/${id}`
44
- });
45
- }
46
- }
114
+ LINK_PATTERN.lastIndex = 0;
115
+ let m;
116
+ while ((m = LINK_PATTERN.exec(content)) !== null) {
117
+ const parsed = parseGithubPath(normalizePath(m[1]));
118
+ if (!parsed)
119
+ continue;
120
+ if (seen.has(parsed.id))
121
+ continue;
122
+ seen.add(parsed.id);
123
+ results.push({
124
+ platform: exports.name,
125
+ type: parsed.type,
126
+ id: parsed.id,
127
+ url: parsed.url,
128
+ });
47
129
  }
48
130
  return results;
49
131
  }
132
+ function getGithubToken(config) {
133
+ const raw = String(config.githubToken || '').trim();
134
+ // 允许直接粘贴 "Bearer xxx" 或 "token xxx"
135
+ return raw.replace(/^(?:bearer|token)\s+/i, '');
136
+ }
137
+ function requestOptions(config) {
138
+ const options = {
139
+ headers: {
140
+ 'User-Agent': config.userAgent,
141
+ 'Accept': 'application/vnd.github.v3+json',
142
+ },
143
+ };
144
+ const token = getGithubToken(config);
145
+ if (token) {
146
+ options.headers.Authorization = `Bearer ${token}`;
147
+ }
148
+ // 适配插件全局配置里的代理选项
149
+ if (config.proxy_settings[exports.name] && config.proxy) {
150
+ options.proxyAgent = config.proxy;
151
+ }
152
+ return options;
153
+ }
154
+ async function getOptional(ctx, config, url) {
155
+ try {
156
+ return await ctx.http.get(url, requestOptions(config));
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ }
162
+ function formatDate(value) {
163
+ if (!value)
164
+ return '未知';
165
+ const date = new Date(value);
166
+ if (isNaN(date.getTime()))
167
+ return '未知';
168
+ return date.toLocaleString('zh-CN', { hour12: false });
169
+ }
170
+ function shortSha(sha) {
171
+ return sha ? sha.slice(0, 7) : '';
172
+ }
173
+ function repoApiUrl(owner, repo) {
174
+ return `${API_BASE}/repos/${owner}/${repo}`;
175
+ }
176
+ function socialifyFlag(value, fallback) {
177
+ return (value === undefined ? fallback : value) ? '1' : '0';
178
+ }
179
+ function buildSocialifyUrl(config, owner, repo) {
180
+ // Socialify 的 /image 返回 SVG,部分适配器无法发送,这里固定取 /png
181
+ const params = [
182
+ `font=${encodeURIComponent(config.socialifyFont || 'Inter')}`,
183
+ `theme=${encodeURIComponent(config.socialifyTheme || 'Light')}`,
184
+ `pattern=${encodeURIComponent(config.socialifyPattern || 'Plus')}`,
185
+ `language=${socialifyFlag(config.socialifyLanguage, true)}`,
186
+ `owner=${socialifyFlag(config.socialifyOwner, true)}`,
187
+ `name=${socialifyFlag(config.socialifyName, true)}`,
188
+ `description=${socialifyFlag(config.socialifyDescription, false)}`,
189
+ `stargazers=${socialifyFlag(config.socialifyStars, true)}`,
190
+ `forks=${socialifyFlag(config.socialifyForks, true)}`,
191
+ `issues=${socialifyFlag(config.socialifyIssues, false)}`,
192
+ `pulls=${socialifyFlag(config.socialifyPulls, false)}`,
193
+ ];
194
+ return `https://socialify.git.ci/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/png?${params.join('&')}`;
195
+ }
50
196
  /**
51
- * 处理单个 GitHub 链接
197
+ * 生成仓库封面图地址,用户/组织主页没有对应预览图,返回空
198
+ */
199
+ function buildCoverUrl(config, owner, repo) {
200
+ if (!repo)
201
+ return '';
202
+ const mode = config.githubCover || 'off';
203
+ if (mode === 'off')
204
+ return '';
205
+ if (mode === 'socialify') {
206
+ return buildSocialifyUrl(config, owner, repo);
207
+ }
208
+ return `https://opengraph.githubassets.com/1/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
209
+ }
210
+ /**
211
+ * 解析单个 GitHub 链接
52
212
  */
53
213
  async function process(ctx, config, link, session) {
54
214
  const logger = ctx.logger(`share-links-analysis:${exports.name}`);
55
- // 使用 GitHub 官方开放 API(免鉴权,有速率限制)
56
- const repoUrl = `https://api.github.com/repos/${link.id}`;
215
+ const segments = link.id.split('/');
216
+ const [owner, repo] = segments;
57
217
  try {
58
- logger.debug(`正在请求 GitHub API: ${repoUrl}`);
59
- const reqOptions = {
60
- headers: {
61
- 'User-Agent': config.userAgent,
62
- 'Accept': 'application/vnd.github.v3+json'
63
- }
64
- };
65
- // 适配插件全局配置里的代理选项
66
- if (config.proxy_settings[exports.name] && config.proxy) {
67
- reqOptions.proxyAgent = config.proxy;
68
- }
69
- // 核心请求
70
- const res = await ctx.http.get(repoUrl, reqOptions);
71
- if (!res || !res.full_name) {
72
- throw new Error('GitHub API 返回数据无效或仓库不存在');
218
+ switch (link.type) {
219
+ case 'user':
220
+ case 'org':
221
+ return await processProfile(ctx, config, link, owner, logger);
222
+ case 'repo':
223
+ return await processRepo(ctx, config, link, owner, repo, logger);
224
+ case 'tree':
225
+ return await processTree(ctx, config, link, owner, repo, segments.slice(3), logger);
226
+ case 'branches':
227
+ case 'tags':
228
+ return await processRefList(ctx, config, link, owner, repo, logger);
229
+ case 'releases':
230
+ return await processReleaseList(ctx, config, link, owner, repo, logger);
231
+ case 'release':
232
+ return await processRelease(ctx, config, link, owner, repo, segments.slice(3), logger);
233
+ default:
234
+ return null;
73
235
  }
74
- // 尝试获取最新 Release(如果失败静默忽略,防止因限流导致整个卡片解析失败)
75
- let latestRelease = '';
76
- try {
77
- const releaseRes = await ctx.http.get(`${repoUrl}/releases/latest`, reqOptions);
78
- if (releaseRes && releaseRes.tag_name) {
79
- latestRelease = releaseRes.tag_name;
80
- }
236
+ }
237
+ catch (error) {
238
+ const status = error?.response?.status;
239
+ logger.error(`GitHub 解析失败: ${status ? `[HTTP ${status}] ` : ''}${error.message}`);
240
+ if (status === 401) {
241
+ logger.error('GitHub Token 无效,请检查「GitHub 解析设置」中的 Token');
81
242
  }
82
- catch (e) {
83
- logger.debug(`获取 Release 失败或触发限流: ${link.id}`);
243
+ else if (status === 403 || status === 429) {
244
+ logger.warn('GitHub API 速率限制已用尽,可在「GitHub 解析设置」配置 Token 提升上限');
84
245
  }
85
- const title = res.full_name;
86
- const authorName = res.owner?.login || 'Unknown';
87
- // 提取高价值静态数据
88
- const topics = res.topics && res.topics.length > 0 ? res.topics.slice(0, 5).join(', ') : '';
89
- const updatedAt = new Date(res.pushed_at || res.updated_at).toLocaleString('zh-CN', { hour12: false });
90
- const stars = (0, utils_1.numeral)(res.stargazers_count, config);
91
- const forks = (0, utils_1.numeral)(res.forks_count, config);
92
- const watchers = (0, utils_1.numeral)(res.subscribers_count || res.watchers_count, config);
93
- const issues = (0, utils_1.numeral)(res.open_issues_count, config);
94
- const statsString = `Stars: ${stars} | Forks: ${forks} | Watchers: ${watchers} | Issues: ${issues}`;
95
- // 组装精炼的正文排版
96
- let finalBody = res.description ? (0, utils_1.escapeHtml)(res.description) : '暂无描述';
97
- // 补充 Fork 信息:判断是否为派生仓库
98
- if (res.fork && res.parent?.full_name) {
99
- finalBody = `Forked from: ${res.parent.full_name}\n\n` + finalBody;
246
+ return null;
247
+ }
248
+ }
249
+ /**
250
+ * 仓库主页
251
+ */
252
+ async function processRepo(ctx, config, link, owner, repo, logger) {
253
+ const repoUrl = repoApiUrl(owner, repo);
254
+ logger.debug(`正在请求 GitHub API: ${repoUrl}`);
255
+ const res = await ctx.http.get(repoUrl, requestOptions(config));
256
+ if (!res || !res.full_name) {
257
+ throw new Error('GitHub API 返回数据无效或仓库不存在');
258
+ }
259
+ // 尝试获取最新 Release(如果失败静默忽略,防止因限流导致整个卡片解析失败)
260
+ let latestRelease = '';
261
+ try {
262
+ const releaseRes = await ctx.http.get(`${repoUrl}/releases/latest`, requestOptions(config));
263
+ if (releaseRes && releaseRes.tag_name) {
264
+ latestRelease = releaseRes.tag_name;
100
265
  }
101
- if (topics) {
102
- finalBody += `\n标签: ${(0, utils_1.escapeHtml)(topics)}`;
266
+ }
267
+ catch (e) {
268
+ logger.debug(`获取 Release 失败或触发限流: ${link.id}`);
269
+ }
270
+ const title = res.full_name;
271
+ const authorName = res.owner?.login || 'Unknown';
272
+ // 提取高价值静态数据
273
+ const topics = res.topics && res.topics.length > 0 ? res.topics.slice(0, 5).join(', ') : '';
274
+ const updatedAt = formatDate(res.pushed_at || res.updated_at);
275
+ const stars = (0, utils_1.numeral)(res.stargazers_count, config);
276
+ const forks = (0, utils_1.numeral)(res.forks_count, config);
277
+ const watchers = (0, utils_1.numeral)(res.subscribers_count || res.watchers_count, config);
278
+ const issues = (0, utils_1.numeral)(res.open_issues_count, config);
279
+ const statsString = `Stars: ${stars} | Forks: ${forks} | Watchers: ${watchers} | Issues: ${issues}`;
280
+ // 组装精炼的正文排版
281
+ let finalBody = res.description ? (0, utils_1.escapeHtml)(res.description) : '暂无描述';
282
+ // 补充 Fork 信息:判断是否为派生仓库
283
+ if (res.fork && res.parent?.full_name) {
284
+ finalBody = `Forked from: ${res.parent.full_name}\n\n` + finalBody;
285
+ }
286
+ if (topics) {
287
+ finalBody += `\n标签: ${(0, utils_1.escapeHtml)(topics)}`;
288
+ }
289
+ finalBody += `\n\n更新时间: ${updatedAt}`;
290
+ const metaInfo = [];
291
+ if (res.language)
292
+ metaInfo.push(`语言: ${res.language}`);
293
+ if (latestRelease)
294
+ metaInfo.push(`版本: ${latestRelease}`);
295
+ if (res.license?.spdx_id)
296
+ metaInfo.push(`协议: ${res.license.spdx_id}`);
297
+ if (metaInfo.length > 0) {
298
+ finalBody += `\n` + metaInfo.join(' | ');
299
+ }
300
+ return {
301
+ platform: exports.name,
302
+ title: title,
303
+ authorName: authorName,
304
+ mainbody: finalBody,
305
+ coverUrl: buildCoverUrl(config, owner, repo),
306
+ files: [],
307
+ sourceUrl: res.html_url,
308
+ stats: statsString,
309
+ };
310
+ }
311
+ /**
312
+ * 用户或组织主页
313
+ */
314
+ async function processProfile(ctx, config, link, login, logger) {
315
+ const data = await ctx.http.get(`${API_BASE}/users/${encodeURIComponent(login)}`, requestOptions(config));
316
+ if (!data || !data.login) {
317
+ throw new Error('GitHub 用户或组织不存在');
318
+ }
319
+ const isOrg = data.type === 'Organization';
320
+ // /users 接口不返回组织简介,组织主页需要再取一次 /orgs
321
+ const orgData = isOrg ? await getOptional(ctx, config, `${API_BASE}/orgs/${encodeURIComponent(login)}`) : null;
322
+ const description = orgData?.description || data.description || data.bio;
323
+ const lines = [];
324
+ if (description)
325
+ lines.push((0, utils_1.escapeHtml)(String(description)));
326
+ if (data.company)
327
+ lines.push(`公司: ${(0, utils_1.escapeHtml)(data.company)}`);
328
+ if (data.location)
329
+ lines.push(`位置: ${(0, utils_1.escapeHtml)(data.location)}`);
330
+ if (data.blog)
331
+ lines.push(`主页: ${(0, utils_1.escapeHtml)(data.blog)}`);
332
+ if (data.twitter_username)
333
+ lines.push(`Twitter: @${(0, utils_1.escapeHtml)(data.twitter_username)}`);
334
+ lines.push(`类型: ${isOrg ? '组织' : '用户'}${orgData?.is_verified ? ' (已认证)' : ''}`);
335
+ lines.push(`创建时间: ${formatDate(data.created_at)}`);
336
+ const statsString = [
337
+ `仓库: ${(0, utils_1.numeral)(data.public_repos || 0, config)}`,
338
+ `关注者: ${(0, utils_1.numeral)(data.followers || 0, config)}`,
339
+ `关注: ${(0, utils_1.numeral)(data.following || 0, config)}`,
340
+ ].join(' | ');
341
+ return {
342
+ platform: exports.name,
343
+ title: data.name || data.login,
344
+ authorName: data.login,
345
+ mainbody: lines.join('\n'),
346
+ coverUrl: '',
347
+ files: [],
348
+ sourceUrl: data.html_url || link.url,
349
+ stats: statsString,
350
+ };
351
+ }
352
+ async function getLatestCommit(ctx, config, owner, repo, ref) {
353
+ try {
354
+ const url = `${repoApiUrl(owner, repo)}/commits?sha=${encodeURIComponent(ref)}&per_page=1`;
355
+ const res = await ctx.http.get(url, requestOptions(config));
356
+ return Array.isArray(res) && res.length > 0 ? res[0] : null;
357
+ }
358
+ catch {
359
+ return null;
360
+ }
361
+ }
362
+ async function listRefNames(ctx, config, owner, repo, kind) {
363
+ try {
364
+ const res = await ctx.http.get(`${repoApiUrl(owner, repo)}/${kind}?per_page=100`, requestOptions(config));
365
+ if (!Array.isArray(res))
366
+ return [];
367
+ return res.map((item) => item?.name).filter((value) => typeof value === 'string');
368
+ }
369
+ catch {
370
+ return [];
371
+ }
372
+ }
373
+ /**
374
+ * 在已知引用名中找出与路径片段前缀最长匹配的一项
375
+ */
376
+ function longestRefPrefix(names, segments) {
377
+ let best = null;
378
+ let bestLength = 0;
379
+ for (const ref of names) {
380
+ const parts = ref.split('/');
381
+ if (parts.length === 0 || parts.length > segments.length || parts.length <= bestLength)
382
+ continue;
383
+ let matched = true;
384
+ for (let i = 0; i < parts.length; i++) {
385
+ if (parts[i] !== segments[i]) {
386
+ matched = false;
387
+ break;
388
+ }
103
389
  }
104
- finalBody += `\n\n更新时间: ${updatedAt}`;
105
- const metaInfo = [];
106
- if (res.language)
107
- metaInfo.push(`语言: ${res.language}`);
108
- if (latestRelease)
109
- metaInfo.push(`版本: ${latestRelease}`);
110
- if (res.license?.spdx_id)
111
- metaInfo.push(`协议: ${res.license.spdx_id}`);
112
- if (metaInfo.length > 0) {
113
- finalBody += `\n` + metaInfo.join(' | ');
390
+ if (!matched)
391
+ continue;
392
+ best = { ref, path: segments.slice(parts.length).join('/') };
393
+ bestLength = parts.length;
394
+ }
395
+ return best;
396
+ }
397
+ /**
398
+ * 解析 /tree/<ref> 里的引用:<ref> 之后可能还跟着目录路径,分支/标签名本身也可能带斜杠
399
+ */
400
+ async function resolveTreeRef(ctx, config, owner, repo, segments, logger) {
401
+ if (segments.length === 0)
402
+ return null;
403
+ // 单段时不存在歧义,直接当作引用
404
+ if (segments.length === 1) {
405
+ const commit = await getLatestCommit(ctx, config, owner, repo, segments[0]);
406
+ if (!commit)
407
+ return null;
408
+ return { ref: segments[0], path: '', commit };
409
+ }
410
+ // 先尝试整段作为引用,例如分支 feature/xxx
411
+ const full = segments.join('/');
412
+ const fullCommit = await getLatestCommit(ctx, config, owner, repo, full);
413
+ if (fullCommit) {
414
+ return { ref: full, path: '', commit: fullCommit };
415
+ }
416
+ const branches = await listRefNames(ctx, config, owner, repo, 'branches');
417
+ const branchHit = longestRefPrefix(branches, segments);
418
+ if (branchHit) {
419
+ const commit = await getLatestCommit(ctx, config, owner, repo, branchHit.ref);
420
+ if (commit)
421
+ return { ref: branchHit.ref, kind: 'branch', path: branchHit.path, commit };
422
+ }
423
+ const tags = await listRefNames(ctx, config, owner, repo, 'tags');
424
+ const tagHit = longestRefPrefix(tags, segments);
425
+ if (tagHit) {
426
+ const commit = await getLatestCommit(ctx, config, owner, repo, tagHit.ref);
427
+ if (commit)
428
+ return { ref: tagHit.ref, kind: 'tag', path: tagHit.path, commit };
429
+ }
430
+ // 兜底:把首段当作引用,其余部分当作目录
431
+ logger.debug(`无法在分支/标签列表中确认引用,按首段解析: ${segments[0]}`);
432
+ const fallbackCommit = await getLatestCommit(ctx, config, owner, repo, segments[0]);
433
+ if (!fallbackCommit)
434
+ return null;
435
+ return { ref: segments[0], path: segments.slice(1).join('/'), commit: fallbackCommit };
436
+ }
437
+ /**
438
+ * 分支或标签页面
439
+ */
440
+ async function processTree(ctx, config, link, owner, repo, segments, logger) {
441
+ const resolved = await resolveTreeRef(ctx, config, owner, repo, segments, logger);
442
+ if (!resolved) {
443
+ throw new Error('未找到对应的分支或标签');
444
+ }
445
+ const { ref, kind, path, commit } = resolved;
446
+ const kindLabel = kind === 'branch' ? '分支' : kind === 'tag' ? '标签' : '引用';
447
+ const lines = [];
448
+ lines.push(`仓库: ${owner}/${repo}`);
449
+ lines.push(`${kindLabel}: ${ref}`);
450
+ if (path)
451
+ lines.push(`路径: ${path}`);
452
+ let commitAuthor = '';
453
+ let commitDate = '';
454
+ if (commit) {
455
+ const message = String(commit.commit?.message || '').split('\n').filter(Boolean);
456
+ if (message.length > 0) {
457
+ lines.push('');
458
+ lines.push((0, utils_1.escapeHtml)(message.slice(0, 2).join('\n')));
114
459
  }
115
- return {
116
- platform: exports.name,
117
- title: title,
118
- authorName: authorName,
119
- mainbody: finalBody,
120
- coverUrl: '',
121
- files: [],
122
- sourceUrl: res.html_url,
123
- stats: statsString,
124
- };
460
+ commitAuthor = commit.author?.login || commit.commit?.author?.name || '';
461
+ commitDate = formatDate(commit.commit?.committer?.date || commit.commit?.author?.date);
462
+ const commitLine = [
463
+ shortSha(commit.sha),
464
+ commitAuthor,
465
+ commitDate,
466
+ ].filter(Boolean).join(' | ');
467
+ lines.push('');
468
+ lines.push(`提交: ${(0, utils_1.escapeHtml)(commitLine)}`);
125
469
  }
126
- catch (error) {
127
- logger.error(`GitHub 解析失败: ${error.message}`);
470
+ const title = path ? `${owner}/${repo} · ${ref}/${path}` : `${owner}/${repo} · ${ref}`;
471
+ return {
472
+ platform: exports.name,
473
+ title,
474
+ authorName: commitAuthor || owner,
475
+ mainbody: lines.join('\n'),
476
+ coverUrl: buildCoverUrl(config, owner, repo),
477
+ files: [],
478
+ sourceUrl: link.url,
479
+ stats: commitDate ? `最近提交: ${commitDate}` : '',
480
+ };
481
+ }
482
+ /**
483
+ * 分支列表或标签列表页面
484
+ */
485
+ async function processRefList(ctx, config, link, owner, repo, logger) {
486
+ const kind = link.type === 'branches' ? 'branches' : 'tags';
487
+ const label = kind === 'branches' ? '分支' : '标签';
488
+ logger.debug(`正在请求 GitHub API: ${repoApiUrl(owner, repo)}/${kind}`);
489
+ const res = await ctx.http.get(`${repoApiUrl(owner, repo)}/${kind}?per_page=${LIST_LIMIT}`, requestOptions(config));
490
+ if (!Array.isArray(res)) {
491
+ throw new Error(`GitHub API 返回的${label}列表无效`);
492
+ }
493
+ const lines = res.length > 0
494
+ ? res.map((item) => {
495
+ const sha = shortSha(item?.commit?.sha);
496
+ const extra = item?.protected ? ' (受保护)' : '';
497
+ return `- ${(0, utils_1.escapeHtml)(String(item?.name || ''))}${sha ? ` (${sha})` : ''}${extra}`;
498
+ })
499
+ : [`暂无${label}`];
500
+ return {
501
+ platform: exports.name,
502
+ title: `${owner}/${repo} · ${label}列表`,
503
+ authorName: owner,
504
+ mainbody: lines.join('\n'),
505
+ coverUrl: buildCoverUrl(config, owner, repo),
506
+ files: [],
507
+ sourceUrl: link.url,
508
+ stats: res.length > 0 ? `显示前 ${res.length} 个` : '',
509
+ };
510
+ }
511
+ /**
512
+ * 发行版列表页面
513
+ */
514
+ async function processReleaseList(ctx, config, link, owner, repo, logger) {
515
+ logger.debug(`正在请求 GitHub API: ${repoApiUrl(owner, repo)}/releases`);
516
+ const res = await ctx.http.get(`${repoApiUrl(owner, repo)}/releases?per_page=${LIST_LIMIT}`, requestOptions(config));
517
+ if (!Array.isArray(res)) {
518
+ throw new Error('GitHub API 返回的发行版列表无效');
519
+ }
520
+ const lines = res.length > 0
521
+ ? res.map((item) => {
522
+ const name = item?.name || item?.tag_name || '未命名';
523
+ const tags = [
524
+ item?.prerelease ? '预发布' : '',
525
+ item?.draft ? '草稿' : '',
526
+ ].filter(Boolean).join('、');
527
+ const date = item?.published_at ? formatDate(item.published_at) : '未发布';
528
+ return `- ${(0, utils_1.escapeHtml)(String(name))} (${(0, utils_1.escapeHtml)(String(item?.tag_name || ''))}) · ${date}${tags ? ` [${tags}]` : ''}`;
529
+ })
530
+ : ['暂无发行版'];
531
+ return {
532
+ platform: exports.name,
533
+ title: `${owner}/${repo} · 发行版列表`,
534
+ authorName: owner,
535
+ mainbody: lines.join('\n'),
536
+ coverUrl: buildCoverUrl(config, owner, repo),
537
+ files: [],
538
+ sourceUrl: link.url,
539
+ stats: res.length > 0 ? `显示前 ${res.length} 个` : '',
540
+ };
541
+ }
542
+ /**
543
+ * 发行版详情,支持 /releases/latest 与 /releases/tag/<tag>
544
+ */
545
+ async function processRelease(ctx, config, link, owner, repo, segments, logger) {
546
+ const repoUrl = repoApiUrl(owner, repo);
547
+ let data;
548
+ if (segments.length === 1 && segments[0].toLowerCase() === 'latest') {
549
+ logger.debug(`正在请求 GitHub API: ${repoUrl}/releases/latest`);
550
+ data = await ctx.http.get(`${repoUrl}/releases/latest`, requestOptions(config));
551
+ }
552
+ else if (segments[0]?.toLowerCase() === 'tag' && segments.length > 1) {
553
+ const tag = segments.slice(1).join('/');
554
+ logger.debug(`正在请求 GitHub API: ${repoUrl}/releases/tags/${tag}`);
555
+ data = await ctx.http.get(`${repoUrl}/releases/tags/${encodeURIComponent(tag)}`, requestOptions(config));
556
+ }
557
+ else {
128
558
  return null;
129
559
  }
560
+ if (!data || !data.tag_name) {
561
+ throw new Error('GitHub API 返回的发行版数据无效');
562
+ }
563
+ const assets = Array.isArray(data.assets) ? data.assets : [];
564
+ const downloads = assets.reduce((sum, asset) => sum + (asset?.download_count || 0), 0);
565
+ const lines = [];
566
+ lines.push(`仓库: ${owner}/${repo}`);
567
+ lines.push(`标签: ${(0, utils_1.escapeHtml)(String(data.tag_name))}`);
568
+ lines.push(`发布: ${formatDate(data.published_at || data.created_at)}`);
569
+ const flags = [
570
+ data.prerelease ? '预发布' : '',
571
+ data.draft ? '草稿' : '',
572
+ ].filter(Boolean);
573
+ if (flags.length > 0)
574
+ lines.push(`状态: ${flags.join('、')}`);
575
+ if (data.author?.login)
576
+ lines.push(`发布者: ${(0, utils_1.escapeHtml)(data.author.login)}`);
577
+ const body = String(data.body || '')
578
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
579
+ .replace(/\n{3,}/g, '\n\n')
580
+ .trim();
581
+ if (body) {
582
+ const excerpt = body.length > RELEASE_BODY_LIMIT
583
+ ? body.slice(0, RELEASE_BODY_LIMIT) + '…'
584
+ : body;
585
+ lines.push('');
586
+ lines.push((0, utils_1.escapeHtml)(excerpt));
587
+ }
588
+ return {
589
+ platform: exports.name,
590
+ title: data.name || data.tag_name,
591
+ authorName: data.author?.login || owner,
592
+ mainbody: lines.join('\n'),
593
+ coverUrl: buildCoverUrl(config, owner, repo),
594
+ files: [],
595
+ sourceUrl: data.html_url || link.url,
596
+ stats: `附件: ${assets.length} | 下载: ${(0, utils_1.numeral)(downloads, config)}`,
597
+ };
130
598
  }
@@ -34,7 +34,7 @@ const linkRules = [
34
34
  type: "explore",
35
35
  },
36
36
  {
37
- pattern: /(?:https?:\/\/)?xhslink\.com\/(?:\w\/)?([a-zA-Z0-9]+)\b/gi,
37
+ pattern: /(?:https?:\/\/)?xhslink\.(?:com|cn)\/(?:\w\/)?([a-zA-Z0-9]+)\b/gi,
38
38
  type: "short",
39
39
  },
40
40
  ];
@@ -153,7 +153,7 @@ async function lc_get_cookie(ctx, config) {
153
153
  }
154
154
  /**
155
155
  * 从分享链接中解析出笔记 ID 与完整笔记地址。
156
- * 短链只跟随第一跳 (xhslink.com 的 Location 里已经带着完整地址和 token),不需要登录态。
156
+ * 短链只跟随第一跳 (xhslink.com | xhslink.cn 的 Location 里已经带着完整地址和 token),不需要登录态。
157
157
  */
158
158
  async function resolveShareLink(ctx, url, config, logger, proxy) {
159
159
  const direct = parseNoteLink(url);
@@ -280,7 +280,7 @@ async function fetchNoteHtml(ctx, config, logger, noteUrl) {
280
280
  throw lastError ?? new Error('获取小红书页面失败');
281
281
  }
282
282
  /**
283
- * 短链兜底:页面内 fetch 到 xhslink.com 属跨源会被拦,改为直接导航,
283
+ * 短链兜底:页面内 fetch 到 xhslink.com | xhslink.cn 属跨源会被拦,改为直接导航,
284
284
  * 再从最终地址里取出带 xsec_token 的笔记地址。
285
285
  */
286
286
  async function followShortLink(ctx, config, logger, shortUrl) {
package/lib/types.d.ts CHANGED
@@ -60,6 +60,19 @@ export interface PluginConfig {
60
60
  allow_sensitive: boolean;
61
61
  youtube_ApiUrl: string;
62
62
  netease_apiUrl: string;
63
+ githubToken?: string;
64
+ githubCover?: 'off' | 'opengraph' | 'socialify';
65
+ socialifyTheme?: 'Light' | 'Dark';
66
+ socialifyFont?: string;
67
+ socialifyPattern?: string;
68
+ socialifyLanguage?: boolean;
69
+ socialifyOwner?: boolean;
70
+ socialifyName?: boolean;
71
+ socialifyDescription?: boolean;
72
+ socialifyStars?: boolean;
73
+ socialifyForks?: boolean;
74
+ socialifyIssues?: boolean;
75
+ socialifyPulls?: boolean;
63
76
  onebotReadDir: string;
64
77
  localDownloadDir: string;
65
78
  userAgent: string;
package/lib/utils.d.ts CHANGED
@@ -4,6 +4,14 @@ import { Agent as HttpAgent } from 'http';
4
4
  import { Agent as HttpsAgent } from 'https';
5
5
  import 'koishi-plugin-adapter-onebot';
6
6
  export declare function buildTelemetryEndpoint(baseUrl: string, path: string): string;
7
+ /**
8
+ * 把各种表示开关的输入统一映射为布尔值
9
+ * 支持布尔值、数字 0/1,以及大小写不敏感的 true/false、enable/disable、on/off、yes/no、
10
+ * 开启/关闭 等写法
11
+ * @param value 待识别的输入
12
+ * @returns 识别成功返回 true 或 false,无法识别返回 undefined
13
+ */
14
+ export declare function parseBoolean(value: unknown): boolean | undefined;
7
15
  /**
8
16
  * 主动同步:从 CookieCloud 拉取并更新数据库
9
17
  * @returns 更新了多少个平台的 Cookie
package/lib/utils.js CHANGED
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.buildTelemetryEndpoint = buildTelemetryEndpoint;
40
+ exports.parseBoolean = parseBoolean;
40
41
  exports.syncCookiesFromCloud = syncCookiesFromCloud;
41
42
  exports.getCookie = getCookie;
42
43
  exports.expandShortLink = expandShortLink;
@@ -110,6 +111,34 @@ function buildTelemetryEndpoint(baseUrl, path) {
110
111
  const cleanBase = baseUrl.replace(/\/+$/, '');
111
112
  return `${cleanBase}${path}`;
112
113
  }
114
+ /**
115
+ * 把各种表示开关的输入统一映射为布尔值
116
+ * 支持布尔值、数字 0/1,以及大小写不敏感的 true/false、enable/disable、on/off、yes/no、
117
+ * 开启/关闭 等写法
118
+ * @param value 待识别的输入
119
+ * @returns 识别成功返回 true 或 false,无法识别返回 undefined
120
+ */
121
+ function parseBoolean(value) {
122
+ if (typeof value === 'boolean')
123
+ return value;
124
+ if (typeof value === 'number') {
125
+ if (value === 1)
126
+ return true;
127
+ if (value === 0)
128
+ return false;
129
+ return undefined;
130
+ }
131
+ if (typeof value !== 'string')
132
+ return undefined;
133
+ const normalized = value.trim().toLowerCase();
134
+ const truthy = ['true', 't', 'yes', 'y', 'on', 'enable', 'enabled', '1', '开', '开启', '打开', '启用', '是'];
135
+ const falsy = ['false', 'f', 'no', 'n', 'off', 'disable', 'disabled', '0', '关', '关闭', '禁用', '否'];
136
+ if (truthy.includes(normalized))
137
+ return true;
138
+ if (falsy.includes(normalized))
139
+ return false;
140
+ return undefined;
141
+ }
113
142
  /**
114
143
  * OpenSSL EVP_BytesToKey 实现
115
144
  */
@@ -415,12 +444,45 @@ function parseHtmlToSegments(html) {
415
444
  }
416
445
  return segments;
417
446
  }
447
+ const CONTENT_TYPE_EXT = {
448
+ 'image/jpeg': '.jpg',
449
+ 'image/png': '.png',
450
+ 'image/gif': '.gif',
451
+ 'image/webp': '.webp',
452
+ 'image/avif': '.avif',
453
+ 'image/bmp': '.bmp',
454
+ 'image/svg+xml': '.svg',
455
+ 'video/mp4': '.mp4',
456
+ 'video/webm': '.webm',
457
+ 'video/quicktime': '.mov',
458
+ 'audio/mpeg': '.mp3',
459
+ 'audio/mp4': '.m4a',
460
+ 'audio/aac': '.aac',
461
+ 'audio/ogg': '.ogg',
462
+ 'audio/wav': '.wav',
463
+ 'audio/flac': '.flac',
464
+ };
465
+ /**
466
+ * 从响应头 Content-Type 推断文件扩展名,URL 路径里没有扩展名时使用
467
+ */
468
+ function extensionFromContentType(contentType) {
469
+ const mime = contentType.split(';')[0].trim().toLowerCase();
470
+ if (CONTENT_TYPE_EXT[mime])
471
+ return CONTENT_TYPE_EXT[mime];
472
+ const [type, subtype] = mime.split('/');
473
+ if ((type === 'image' || type === 'video' || type === 'audio') && subtype) {
474
+ const cleaned = subtype.replace(/[^a-z0-9]/g, '');
475
+ if (cleaned && cleaned !== 'octetstream')
476
+ return `.${cleaned}`;
477
+ }
478
+ return '.bin';
479
+ }
418
480
  async function downloadAndMapUrl(ctx, url, proxy, userAgent, localDownloadDir, onebotReadDir, logger, enableCache, statsRef, cacheKey) {
419
481
  await fs.promises.mkdir(localDownloadDir, { recursive: true });
420
482
  // 1. 计算 Hash
421
483
  const hash = getFileCacheHash(url, cacheKey);
422
484
  const u = new url_1.URL(url);
423
- const ext = path_1.default.extname(u.pathname).split('?')[0] || '.bin';
485
+ const urlExt = path_1.default.extname(u.pathname).split('?')[0];
424
486
  if (enableCache) {
425
487
  try {
426
488
  const cached = await findExistingFileCache(ctx, url, cacheKey);
@@ -474,22 +536,26 @@ async function downloadAndMapUrl(ctx, url, proxy, userAgent, localDownloadDir, o
474
536
  }
475
537
  }
476
538
  // 2. 生成文件名 (使用 Hash 以实现去重)
477
- const safeFilename = `${hash}${ext}`;
478
- const actualPath = path_1.default.join(localDownloadDir, safeFilename);
479
- const onebotPath = path_1.default.posix.join(onebotReadDir, safeFilename);
480
- const fileUrl = `file://${onebotPath}`;
539
+ // URL 自带扩展名时可以直接落盘;猜不到扩展名时等拿到 Content-Type 再决定
540
+ const buildTarget = (ext) => {
541
+ const safeFilename = `${hash}${ext}`;
542
+ const actualPath = path_1.default.join(localDownloadDir, safeFilename);
543
+ const onebotPath = path_1.default.posix.join(onebotReadDir, safeFilename);
544
+ return { actualPath, fileUrl: `file://${onebotPath}` };
545
+ };
546
+ let target = urlExt ? buildTarget(urlExt) : null;
481
547
  // 3. 检查本地文件是否存在 (双重保险,或者应对未清理的情况)
482
- if (enableCache && fs.existsSync(actualPath)) {
548
+ if (enableCache && target && fs.existsSync(target.actualPath)) {
483
549
  // 补写数据库
484
550
  await ctx.database.upsert('sla_file_cache', [{
485
551
  hash,
486
- path: actualPath,
552
+ path: target.actualPath,
487
553
  url,
488
554
  created_at: Date.now()
489
555
  }]);
490
556
  if (statsRef)
491
557
  statsRef.fileCacheHits = (statsRef.fileCacheHits || 0) + 1;
492
- return fileUrl;
558
+ return target.fileUrl;
493
559
  }
494
560
  const attemptDownload = () => new Promise((resolve, reject) => {
495
561
  const agent = getProxyAgent(proxy, url);
@@ -523,6 +589,11 @@ async function downloadAndMapUrl(ctx, url, proxy, userAgent, localDownloadDir, o
523
589
  reject(new Error(`Unexpected content type: ${contentType}`));
524
590
  return;
525
591
  }
592
+ if (!target) {
593
+ target = buildTarget(extensionFromContentType(contentType));
594
+ }
595
+ const actualPath = target.actualPath;
596
+ const fileUrl = target.fileUrl;
526
597
  const pipelineAsync = (0, util_1.promisify)(stream_1.pipeline);
527
598
  pipelineAsync(res, (0, fs_1.createWriteStream)(actualPath))
528
599
  .then(async () => {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "koishi-plugin-share-links-analysis",
3
3
  "description": "Koishi 多平台分享链接解析插件,支持内容预览、媒体提取与缓存。",
4
4
  "license": "MIT",
5
- "version": "0.15.3",
5
+ "version": "0.15.4",
6
6
  "main": "lib/index.js",
7
7
  "typings": "lib/index.d.ts",
8
8
  "files": [