优化
This commit is contained in:
7
server/plugins/com.msgbyte.linkmeta/.ministarrc.js
Normal file
7
server/plugins/com.msgbyte.linkmeta/.ministarrc.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
externalDeps: ['react'],
|
||||
pluginRoot: path.resolve(__dirname, './web'),
|
||||
outDir: path.resolve(__dirname, '../../public'),
|
||||
};
|
||||
36
server/plugins/com.msgbyte.linkmeta/models/linkmeta.ts
Normal file
36
server/plugins/com.msgbyte.linkmeta/models/linkmeta.ts
Normal 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;
|
||||
21
server/plugins/com.msgbyte.linkmeta/package.json
Normal file
21
server/plugins/com.msgbyte.linkmeta/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
}
|
||||
`;
|
||||
80
server/plugins/com.msgbyte.linkmeta/test/linkmeta.spec.ts
Normal file
80
server/plugins/com.msgbyte.linkmeta/test/linkmeta.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createPluginRequest } from '@capital/common';
|
||||
|
||||
export const request = createPluginRequest('com.msgbyte.linkmeta');
|
||||
@@ -0,0 +1,8 @@
|
||||
import { localTrans } from '@capital/common';
|
||||
|
||||
export const Translate = {
|
||||
linkmetaService: localTrans({
|
||||
'zh-CN': 'Url元数据服务',
|
||||
'en-US': 'Link Meta Service',
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react",
|
||||
"importsNotUsedAsValues": "error"
|
||||
}
|
||||
}
|
||||
2
server/plugins/com.msgbyte.linkmeta/web/plugins/com.msgbyte.linkmeta/types/tailchat.d.ts
vendored
Normal file
2
server/plugins/com.msgbyte.linkmeta/web/plugins/com.msgbyte.linkmeta/types/tailchat.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare module '@capital/common';
|
||||
declare module '@capital/component';
|
||||
Reference in New Issue
Block a user