This commit is contained in:
2026-04-25 16:36:34 +08:00
commit db90e7579b
1876 changed files with 189777 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
const path = require('path');
module.exports = {
externalDeps: ['react'],
pluginRoot: path.resolve(__dirname, './web'),
outDir: path.resolve(__dirname, '../../public'),
};

View File

@@ -0,0 +1,36 @@
import {
getModelForClass,
DocumentType,
modelOptions,
prop,
Severity,
index,
} from '@typegoose/typegoose';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import type { Types } from 'mongoose';
@modelOptions({
options: {
customName: 'p_linkmeta',
allowMixed: Severity.ALLOW,
},
})
@index({ url: 1 })
export class Linkmeta extends TimeStamps implements Base {
_id: Types.ObjectId;
id: string;
@prop()
url: string;
@prop()
data: any;
}
export type LinkmetaDocument = DocumentType<Linkmeta>;
const model = getModelForClass(Linkmeta);
export type LinkmetaModel = typeof model;
export default model;

View File

@@ -0,0 +1,21 @@
{
"name": "tailchat-plugin-linkmeta",
"version": "1.0.0",
"main": "index.js",
"author": "moonrailgun",
"license": "MIT",
"private": true,
"scripts": {
"build:web": "ministar buildPlugin all",
"build:web:watch": "ministar watchPlugin all"
},
"devDependencies": {
"less": "^4.1.2",
"mini-star": "^1.2.8"
},
"dependencies": {
"got": "11.8.3",
"link-preview-js": "^2.1.10",
"lodash": "^4.17.21"
}
}

View File

@@ -0,0 +1,82 @@
import { TcService, TcContext, TcDbService } from 'tailchat-server-sdk';
import type { LinkmetaDocument, LinkmetaModel } from '../models/linkmeta';
import { fetchLinkPreview } from '../utils/fetchLinkPreview';
import { fetchSpecialWebsiteMeta } from '../utils/specialWebsiteMeta';
/**
* 链接信息服务
*/
interface LinkmetaService
extends TcService,
TcDbService<LinkmetaDocument, LinkmetaModel> {}
class LinkmetaService extends TcService {
get serviceName() {
return 'plugin:com.msgbyte.linkmeta';
}
onInit() {
this.registerLocalDb(require('../models/linkmeta').default);
this.registerAction('fetch', this.fetch, {
params: {
url: 'string',
},
});
}
/**
* 获取连接预览信息
*/
private async fetch(ctx: TcContext<{ url: string }>) {
const url = ctx.params.url;
const meta = await this.adapter.model.findOne(
{
url,
},
undefined,
{
sort: {
_id: -1,
},
}
);
if (
!meta ||
new Date(meta.createdAt).valueOf() <
new Date().valueOf() - 1000 * 60 * 60 * 24
) {
// 没有找到或已过期(过期时间24小时)
const data = await fetchLinkPreview(url);
// 转存图片
if (Array.isArray(data.images) && data.images.length > 0) {
try {
const { url } = (await ctx.call('file.saveFileWithUrl', {
fileUrl: data.images[0],
})) as { url: string };
data.images[0] = url;
} catch (e) {}
}
// 尝试对特定网站获取更多信息
const overwrite = await fetchSpecialWebsiteMeta(url);
Object.assign(data, overwrite);
await this.adapter.model.create({
url,
data,
});
return { ...data, isCache: false };
}
return {
...meta.data,
isCache: true,
};
}
}
export default LinkmetaService;

View File

@@ -0,0 +1,66 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Test "plugin:com.msgbyte.linkinfo" service Test "plugin:com.msgbyte.linkmeta.fetch" normal 1`] = `
Object {
"contentType": "text/html",
"description": undefined,
"favicons": Array [
"https://www.baidu.com/favicon.ico",
],
"images": Array [],
"isCache": false,
"mediaType": "website",
"siteName": undefined,
"title": "",
"url": "https://www.baidu.com/?fortest",
"videos": Array [],
}
`;
exports[`Test "plugin:com.msgbyte.linkinfo" service Test "plugin:com.msgbyte.linkmeta.fetch" pure image 1`] = `
Object {
"contentType": "image/jpeg",
"favicons": Array [
"https://www.w3schools.com/favicon.ico",
],
"isCache": true,
"mediaType": "image",
"url": "https://www.w3schools.com/html/pic_trulli.jpg",
}
`;
exports[`Test "plugin:com.msgbyte.linkinfo" service Test "plugin:com.msgbyte.linkmeta.fetch" pure mp3 1`] = `
Object {
"contentType": "audio/mpeg",
"favicons": Array [
"https://www.w3schools.com/favicon.ico",
],
"isCache": true,
"mediaType": "audio",
"url": "https://www.w3schools.com/html/horse.mp3",
}
`;
exports[`Test "plugin:com.msgbyte.linkinfo" service Test "plugin:com.msgbyte.linkmeta.fetch" pure ogg 1`] = `
Object {
"contentType": "video/ogg",
"favicons": Array [
"https://www.w3schools.com/favicon.ico",
],
"isCache": true,
"mediaType": "video",
"url": "https://www.w3schools.com/html/horse.ogg",
}
`;
exports[`Test "plugin:com.msgbyte.linkinfo" service Test "plugin:com.msgbyte.linkmeta.fetch" pure video 1`] = `
Object {
"contentType": "video/mp4",
"favicons": Array [
"https://www.w3schools.com/favicon.ico",
],
"isCache": true,
"mediaType": "video",
"url": "https://www.w3schools.com/html/mov_bbb.mp4",
}
`;

View File

@@ -0,0 +1,80 @@
import { createTestServiceBroker } from '../../../test/utils';
import LinkmetaService from '../services/linkmeta.service';
import { Types } from 'mongoose';
import _ from 'lodash';
describe('Test "plugin:com.msgbyte.linkinfo" service', () => {
const { broker, service, insertTestData } =
createTestServiceBroker<LinkmetaService>(LinkmetaService);
describe('Test "plugin:com.msgbyte.linkmeta.fetch"', () => {
test('normal', async () => {
const url = 'https://www.baidu.com/?fortest';
const meta: any = await broker.call('plugin:com.msgbyte.linkmeta.fetch', {
url,
});
try {
expect(meta).toHaveProperty('url', url);
expect(meta).toHaveProperty('isCache', false);
expect(meta).toHaveProperty('title');
expect(meta).toHaveProperty('siteName');
expect(meta).toHaveProperty('description');
expect(meta).toHaveProperty('mediaType', 'website');
expect(meta).toHaveProperty('contentType', 'text/html');
expect(meta).toHaveProperty('images');
expect(meta).toHaveProperty('videos');
expect(meta).toHaveProperty('favicons');
expect(meta).toMatchSnapshot();
const metaWithCache: any = await broker.call(
'plugin:com.msgbyte.linkmeta.fetch',
{
url,
}
);
expect(metaWithCache).toHaveProperty('isCache', true);
} finally {
await service.adapter.model.deleteOne({
url,
});
}
});
test('pure video', async () => {
const url = 'https://www.w3schools.com/html/mov_bbb.mp4';
const meta: any = await broker.call('plugin:com.msgbyte.linkmeta.fetch', {
url,
});
expect(meta).toMatchSnapshot();
});
test('pure image', async () => {
const url = 'https://www.w3schools.com/html/pic_trulli.jpg';
const meta: any = await broker.call('plugin:com.msgbyte.linkmeta.fetch', {
url,
});
expect(meta).toMatchSnapshot();
});
test('pure ogg', async () => {
const url = 'https://www.w3schools.com/html/horse.ogg';
const meta: any = await broker.call('plugin:com.msgbyte.linkmeta.fetch', {
url,
});
expect(meta).toMatchSnapshot();
});
test('pure mp3', async () => {
const url = 'https://www.w3schools.com/html/horse.mp3';
const meta: any = await broker.call('plugin:com.msgbyte.linkmeta.fetch', {
url,
});
expect(meta).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,52 @@
import { fetchLinkPreview } from '../fetchLinkPreview';
const mockGetLinkPreviewFn = jest.fn();
jest.mock('link-preview-js', () => ({
getLinkPreview: async () => {
mockGetLinkPreviewFn();
},
}));
describe('Test "fetchLinkPreview"', () => {
test(
'fetchLinkPreview should merge same request',
async () => {
await Promise.all([
fetchLinkPreview('foo'),
fetchLinkPreview('foo'),
fetchLinkPreview('foo'),
fetchLinkPreview('foo'),
fetchLinkPreview('foo'),
fetchLinkPreview('foo'),
fetchLinkPreview('foo'),
fetchLinkPreview('foo'),
]);
expect(mockGetLinkPreviewFn.mock.calls.length).toBe(1);
await sleep(5 * 1000); // 度过窗口期
await Promise.all([
fetchLinkPreview('foo'),
fetchLinkPreview('foo'),
fetchLinkPreview('foo'),
fetchLinkPreview('foo'),
fetchLinkPreview('foo'),
fetchLinkPreview('foo'),
fetchLinkPreview('foo'),
fetchLinkPreview('foo'),
]);
expect(mockGetLinkPreviewFn.mock.calls.length).toBe(2);
},
10 * 1000
);
});
function sleep(ms: number): Promise<void> {
return new Promise((resolve) =>
setTimeout(() => {
resolve();
}, ms)
);
}

View File

@@ -0,0 +1,29 @@
import { getLinkPreview } from 'link-preview-js';
/**
* 请求管理
*/
const cacheRequestList: Record<string, Promise<any>> = {};
/**
* 获取网页元数据信息
* @param url 网址
* @returns
*/
export async function fetchLinkPreview(url: string): Promise<any> {
if (cacheRequestList[url]) {
// 如果有正在请求的信息
return Promise.resolve(cacheRequestList[url]);
}
const promise = getLinkPreview(url);
cacheRequestList[url] = promise;
return Promise.resolve(promise).finally(() => {
setTimeout(() => {
delete cacheRequestList[url];
}, 2 * 1000); // 窗口期, 请求完毕后2s内依旧会复用原来的接口
});
// return promise;
}

View File

@@ -0,0 +1,49 @@
import got from 'got';
import _ from 'lodash';
/**
* 获取特定页面的信息
*/
// <iframe src="//player.bilibili.com/player.html?aid=938355060&bvid=BV1bT4y1a7RH&cid=577883291&page=1" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true"> </iframe>
const specialWebsiteMetaFetchers = [
{
// bilibili
match: (url: string) => url.startsWith('https://www.bilibili.com/video/BV'),
overwrite: async (url: string) => {
// from https://github.com/simon300000/bili-api/blob/master/src/api/api.bilibili.com.js
const bvid = _.last(url.split('?')[0].split('/').filter(Boolean));
const { data } = await got(
`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`
).json<any>();
const aid = _.get(data, 'aid');
const cid = _.get(data, 'cid');
if (aid && bvid && cid) {
return {
videos: [
`https://player.bilibili.com/player.html?aid=${aid}&bvid=${bvid}&cid=${cid}&page=1&autoplay=0`,
],
};
}
},
},
];
/**
* 获取更多的信息
* @param url 请求数据的地址
*/
export async function fetchSpecialWebsiteMeta(url: string) {
const matched = specialWebsiteMetaFetchers.find((f) => f.match(url));
if (matched) {
const overwrite = await matched.overwrite(url);
return overwrite ?? {};
}
return {};
}

View File

@@ -0,0 +1,11 @@
{
"label": "Url metadata display",
"label.zh-CN": "Url元数据展示",
"name": "com.msgbyte.linkmeta",
"url": "{BACKEND}/plugins/com.msgbyte.linkmeta/index.js",
"version": "0.0.0",
"author": "msgbyte",
"description": "Parse and get the overview of url information in the chat information, such as title/overview/thumbnail, support media path, directly display media player (specially support bilibili, automatically load the iframe player of bilibili)",
"description.zh-CN": "解析并获取在聊天信息中的url信息概述如标题/概述/缩略图, 支持媒体路径,直接显示媒体播放器(特殊支持bilibili自动加载b站iframe播放器)",
"requireRestart": false
}

View File

@@ -0,0 +1,10 @@
{
"name": "@plugins/com.msgbyte.linkmeta",
"main": "src/index.tsx",
"version": "0.0.0",
"private": true,
"dependencies": {
"lodash-es": "^4.17.21",
"url-regex": "^5.0.0"
}
}

View File

@@ -0,0 +1,10 @@
import React from 'react';
import _get from 'lodash/get';
import type { LinkMeta } from './types';
export const UrlMetaAudio: React.FC<{
meta: LinkMeta;
}> = React.memo(({ meta }) => {
return <audio src={meta.url} controls={true} />;
});
UrlMetaAudio.displayName = 'UrlMetaAudio';

View File

@@ -0,0 +1,43 @@
import React from 'react';
import _get from 'lodash/get';
import type { LinkMeta } from './types';
import { parseUrlStr } from '@capital/common';
import { Image, Icon } from '@capital/component';
export const UrlMetaBase: React.FC<{
meta: LinkMeta;
}> = React.memo(({ meta }) => {
const imageUrl = _get(meta, 'images.0');
const videoUrl = _get(meta, 'videos.0');
return (
<>
<div className="basic">
<div className="summary" onClick={() => window.open(meta.url)}>
<div className="title">{_get(meta, 'title')}</div>
<div className="description">{_get(meta, 'description')}</div>
</div>
{imageUrl && (
<div className="image">
<Image preview={true} src={parseUrlStr(imageUrl)} />
</div>
)}
</div>
{videoUrl && (
<div className="video">
<div
className="openfull"
onClick={(e) => {
e.stopPropagation();
window.open(videoUrl);
}}
>
<Icon icon="mdi:open-in-new" />
</div>
<iframe src={videoUrl} />
</div>
)}
</>
);
});
UrlMetaBase.displayName = 'UrlMetaBase';

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { parseUrlStr } from '@capital/common';
import { Image } from '@capital/component';
import type { LinkMeta } from './types';
const MAX_HEIGHT = 320;
const MAX_WIDTH = 320;
export const UrlMetaImage: React.FC<{
meta: LinkMeta;
}> = React.memo(({ meta }) => {
return (
<Image
preview={true}
src={parseUrlStr(meta.url)}
style={{
maxHeight: MAX_HEIGHT,
maxWidth: MAX_WIDTH,
width: 'auto',
}}
/>
);
});
UrlMetaImage.displayName = 'UrlMetaImage';

View File

@@ -0,0 +1,10 @@
import React from 'react';
import _get from 'lodash/get';
import type { LinkMeta } from './types';
export const UrlMetaVideo: React.FC<{
meta: LinkMeta;
}> = React.memo(({ meta }) => {
return <video src={meta.url} controls={true} />;
});
UrlMetaVideo.displayName = 'UrlMetaVideo';

View File

@@ -0,0 +1,82 @@
.plugin-linkmeta-previewer {
background-color: rgba(0, 0, 0, 0.1);
border-radius: 3px;
max-width: 90%;
overflow: hidden;
padding: 10px;
width: max-content;
.basic {
cursor: pointer;
display: flex;
.summary {
flex: 1;
&:hover .title {
text-decoration: underline;
}
.title {
font-weight: bold;
}
.description {
font-size: smaller;
color: grey;
}
}
.image {
margin-left: 16px;
margin-top: 8px;
max-width: 80px;
max-height: 50px;
overflow: hidden;
display: flex;
align-items: center;
}
}
.video {
max-width: 100%;
max-height: 400px;
position: relative;
.openfull {
position: absolute;
left: 8px;
top: 8px;
padding: 4px;
border-radius: 3px;
background-color: rgba(0, 0, 0, 0.2);
color: white;
font-size: 20px;
&:hover {
background-color: rgba(0, 0, 0, 0.4);
}
}
}
@media screen and (max-width: 719px) {
// Hide when vertical screen
.basic {
flex-direction: column;
.image {
max-width: 100%;
max-height: initial;
margin-left: 0;
margin-top: 4px;
overflow: initial;
}
}
.video {
iframe {
max-width: 100%;
}
}
}
}

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { useAsync } from '@capital/common';
import { LoadingSpinner } from '@capital/component';
import { request } from '../request';
import _get from 'lodash/get';
import { UrlMetaBase } from './Base';
import type { LinkMeta } from './types';
import './index.less';
import { UrlMetaRender } from './render';
const metaCache: Record<string, LinkMeta | null> = {};
export const UrlMetaPreviewer: React.FC<{
url: string;
}> = React.memo((props) => {
const {
error,
value: meta,
loading,
} = useAsync(async () => {
if (metaCache[props.url] !== undefined) {
return metaCache[props.url];
}
try {
const { data } = await request.post('fetch', {
url: props.url,
});
metaCache[props.url] = data;
return data;
} catch (e) {
console.warn('[linkmeta] fetch url meta info error', e);
metaCache[props.url] = null;
return null;
}
}, [props.url]);
if (error || meta === null) {
return null;
}
return loading ? (
<div className="plugin-linkmeta-previewer">
<LoadingSpinner />
</div>
) : (
<UrlMetaRender meta={meta} />
);
});
UrlMetaPreviewer.displayName = 'UrlMetaPreviewer';

View File

@@ -0,0 +1,53 @@
import React from 'react';
import _get from 'lodash/get';
import type { LinkMeta } from './types';
import { UrlMetaBase } from './Base';
import { UrlMetaVideo } from './Video';
import { UrlMetaImage } from './Image';
import { UrlMetaAudio } from './Audio';
export const UrlMetaRender: React.FC<{
meta: LinkMeta;
}> = React.memo(({ meta }) => {
const contentType = _get(meta, 'contentType', '');
if (contentType.startsWith('video/')) {
return (
<div className="plugin-linkmeta-previewer">
<UrlMetaVideo meta={meta} />
</div>
);
}
if (contentType.startsWith('image/')) {
return (
<div className="plugin-linkmeta-previewer">
<UrlMetaImage meta={meta} />
</div>
);
}
if (contentType.startsWith('audio/')) {
return (
<div className="plugin-linkmeta-previewer">
<UrlMetaAudio meta={meta} />
</div>
);
}
if (contentType.startsWith('application/')) {
return null;
}
if (meta['title'] === '') {
// 一般网页没有title(没有有用信息),则什么都不显示
return null;
}
// 一般网页
return (
<div className="plugin-linkmeta-previewer">
<UrlMetaBase meta={meta} />
</div>
);
});
UrlMetaRender.displayName = 'UrlMetaRender';

View File

@@ -0,0 +1,21 @@
export type LinkMeta =
| {
// media
contentType: string;
favicons: string[];
mediaType: string;
url: string;
isCache: boolean;
}
| {
url: string;
title: string;
siteName: string;
description: string;
mediaType: string;
contentType: string;
images: string[];
videos: string[];
favicons: string[];
isCache: boolean;
};

View File

@@ -0,0 +1,34 @@
import {
regMessageExtraParser,
regInspectService,
getMessageTextDecorators,
} from '@capital/common';
import { Translate } from './translate';
import urlRegex from 'url-regex';
import React from 'react';
import { UrlMetaPreviewer } from './UrlMetaPreviewer';
regMessageExtraParser({
name: 'com.msgbyte.linkmeta/urlParser',
render({ content }) {
const matched = String(
getMessageTextDecorators().serialize(String(content))
).match(urlRegex());
if (matched) {
const urlMatch = matched
.filter((m) => !m.includes('['))
.filter((m) => !m.startsWith(window.location.origin));
if (urlMatch.length > 0 && typeof urlMatch[0] === 'string') {
return <UrlMetaPreviewer url={urlMatch[0]} />;
}
}
return null;
},
});
regInspectService({
name: 'plugin:com.msgbyte.linkmeta',
label: Translate.linkmetaService,
});

View File

@@ -0,0 +1,3 @@
import { createPluginRequest } from '@capital/common';
export const request = createPluginRequest('com.msgbyte.linkmeta');

View File

@@ -0,0 +1,8 @@
import { localTrans } from '@capital/common';
export const Translate = {
linkmetaService: localTrans({
'zh-CN': 'Url元数据服务',
'en-US': 'Link Meta Service',
}),
};

View File

@@ -0,0 +1,7 @@
{
"compilerOptions": {
"esModuleInterop": true,
"jsx": "react",
"importsNotUsedAsValues": "error"
}
}

View File

@@ -0,0 +1,2 @@
declare module '@capital/common';
declare module '@capital/component';