mirror of
https://github.com/cnwenzhihong/Obsidian-auto-paste-link.git
synced 2026-07-22 07:45:50 +00:00
Release 1.0.4 supported title providers
This commit is contained in:
parent
3365ee3891
commit
b10dd8f18a
20 changed files with 1832 additions and 451 deletions
61
README.md
61
README.md
|
|
@ -54,25 +54,29 @@ Video link:
|
|||
|
||||
Automatic title fetching is intentionally limited to important supported sites:
|
||||
|
||||
- bilibili
|
||||
- YouTube
|
||||
- Fab
|
||||
- GitHub
|
||||
- Stack Overflow
|
||||
- Stack Exchange
|
||||
- Reddit
|
||||
- Wikipedia
|
||||
- Steam
|
||||
- MDN
|
||||
- npm
|
||||
- Zhihu
|
||||
- Juejin
|
||||
- CSDN
|
||||
- WeChat Official Accounts
|
||||
- Douban
|
||||
- CNBlogs
|
||||
```text
|
||||
bilibili, YouTube, Fab, Steam
|
||||
TMDb, Letterboxd, Rotten Tomatoes, MyAnimeList, Bangumi, Spotify, Dailymotion
|
||||
GitHub, GitLab, Gitee, Stack Overflow, Stack Exchange
|
||||
Reddit, Wikipedia
|
||||
MDN, npm, PyPI, crates.io, Go Packages
|
||||
Apple Developer, Unity, Next.js, Vue.js, Node.js
|
||||
Juejin, CSDN, Tencent Cloud Developer, Alibaba Cloud Developer
|
||||
WeChat Official Accounts, Douban, CNBlogs, SegmentFault, Jianshu
|
||||
```
|
||||
|
||||
Other websites are not fetched for titles.
|
||||
Other websites can use the generic title fetcher when the title looks clean enough.
|
||||
|
||||
## Known Unsupported Title Sites
|
||||
|
||||
The following sites were tested and did not expose stable, specific titles to normal plugin requests. They may return verification pages, homepage titles, empty titles, or generic app titles. For these sites, copy the title manually or select the title text before pasting the URL.
|
||||
|
||||
```text
|
||||
IMDb, Netflix, AniList
|
||||
iQIYI, Youku, Mango TV, Maoyan
|
||||
NetEase Cloud Music, QQ Music
|
||||
SoundCloud, Twitch, TikTok
|
||||
```
|
||||
|
||||
## Image Detection
|
||||
|
||||
|
|
@ -172,4 +176,23 @@ corepack pnpm test
|
|||
corepack pnpm build
|
||||
```
|
||||
|
||||
The GitHub release tag must exactly match the version in `manifest.json`, for example `1.0.3` without a `v` prefix. Release assets must include `main.js`, `manifest.json`, and `styles.css`.
|
||||
The GitHub release tag must exactly match the version in `manifest.json`, for example `1.0.4` without a `v` prefix. Release assets must include `main.js`, `manifest.json`, and `styles.css`.
|
||||
|
||||
## Adding Supported Sites With AI
|
||||
|
||||
Only add a site-specific provider when the generic title fetcher returns a bad title and the site has a stable way to produce a cleaner one.
|
||||
|
||||
1. Pick the closest group file in `src/core/titleProviders/groups/`.
|
||||
2. Add a small `TitleProvider` with `id`, `displayName`, `matches`, `createRequests`, and `parse`.
|
||||
3. Prefer stable APIs or oEmbed endpoints when available. Otherwise clean a predictable suffix from `og:title`, `twitter:title`, or `<title>`.
|
||||
4. Do not add sites that only return verification pages, login pages, homepage titles, or vague app titles.
|
||||
5. Register a new group in `src/core/titleProviders/providers.ts` only when no existing group fits.
|
||||
6. Add tests in `tests/core.test.ts` for host matching and title cleanup.
|
||||
7. Update the supported or unsupported site list in this README.
|
||||
8. Run:
|
||||
|
||||
```bash
|
||||
node --experimental-default-type=module --test tests/*.test.ts
|
||||
./node_modules/.bin/tsc.CMD -noEmit -skipLibCheck
|
||||
node esbuild.config.mjs production
|
||||
```
|
||||
|
|
|
|||
|
|
@ -384,6 +384,27 @@ workflow 应防止 tag 和源码不一致:
|
|||
|
||||
不要先手动创建旧 tag,再继续改源码。这样会导致 tag 指向旧提交,Release assets 和源码不一致。
|
||||
|
||||
## 本项目快速发布协作模式
|
||||
|
||||
本项目实际协作方式是 GPT 负责绝大多数本地和远端维护工作,包括代码修改、文档修改、版本文件更新、提交、推送、tag 状态检查和 Release 状态检查。
|
||||
|
||||
用户默认只手动触发 GitHub Actions 里的 Release workflow。因此发布前不需要重复执行早期上架阶段的完整人工检查链路,除非改动涉及发布工作流、构建配置、Obsidian 审核报错或 GitHub Release 失败。
|
||||
|
||||
可以降级为轻量检查的事项:
|
||||
|
||||
- 版本检查:确认 `manifest.json.version` 和 `package.json.version` 相同,且 `versions.json` 包含新版本。
|
||||
- Release assets:默认交给 GitHub Actions 构建、签发 attestations 并上传,不手动复制或比对本地 `release/` 目录。
|
||||
- 本地构建:仅在核心逻辑、构建配置、依赖或类型定义发生变化时运行;纯文档或发布说明修改不需要跑完整构建。
|
||||
- tag 和 Release 查询:发布前确认一次即可,不要反复查询旧版本历史,除非发现不一致。
|
||||
|
||||
不能省的事项:
|
||||
|
||||
- 修改前先看 `git status -sb`。
|
||||
- 改版本时保持 `manifest.json`、`package.json`、`versions.json` 一致。
|
||||
- 影响核心逻辑时运行必要测试或类型检查。
|
||||
- 推送后确认远端 branch 或 tag 已更新。
|
||||
- Release workflow 跑完后确认 GitHub Release 成功生成。
|
||||
|
||||
## Obsidian 社区插件审核通过后
|
||||
|
||||
审核通过后没有“发布按钮”。
|
||||
|
|
@ -434,4 +455,4 @@ node esbuild.config.mjs production
|
|||
5. 明确是否需要更新 Release assets。
|
||||
6. 不要自动提交,除非用户明确要求。
|
||||
7. 如果要发布,优先走 GitHub Actions release workflow。
|
||||
|
||||
8. 本项目由 GPT 负责大部分修改和远端操作,用户通常只手动触发 Release Action;不要默认套用完整人工发布检查流程。
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "auto-paste-link",
|
||||
"name": "Auto Paste Link",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.4",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Auto formats pasted URLs as Markdown links, media embeds, and supported site titles.",
|
||||
"author": "cnwenzhihong",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-auto-paste-link",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.4",
|
||||
"description": "Auto format pasted URLs as Markdown links, media embeds, and supported site titles in Obsidian.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -66,13 +66,15 @@ export class PasteHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!getSupportedTitleProvider(url)) {
|
||||
if (!getSupportedTitleProvider(url) && !settings.fetchGenericSiteTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
void resolveSupportedSiteTitle(url, {
|
||||
request: requestTitle,
|
||||
timeoutMs: settings.titleFetchTimeoutMs,
|
||||
githubTitleFormat: settings.githubTitleFormat,
|
||||
fetchGenericSiteTitle: settings.fetchGenericSiteTitle,
|
||||
}).then((title) => {
|
||||
if (!title || !isEmptyMarkdownLinkTitle(editor, insertion.titleRange!.from)) {
|
||||
return;
|
||||
|
|
|
|||
111
src/core/titleProviders/groups/chineseContent.ts
Normal file
111
src/core/titleProviders/groups/chineseContent.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import type { TitleProvider } from "../types.ts";
|
||||
import {
|
||||
cleanCsdnTitle,
|
||||
cleanSiteSuffix,
|
||||
createHtmlTitleProvider,
|
||||
extractHtmlTitle,
|
||||
HTML_HEADERS,
|
||||
isHost,
|
||||
} from "../utils.ts";
|
||||
|
||||
const juejinProvider = createHtmlTitleProvider({
|
||||
id: "juejin",
|
||||
displayName: "Juejin",
|
||||
domains: ["juejin.cn"],
|
||||
suffixes: [" - 掘金"],
|
||||
});
|
||||
|
||||
const csdnProvider: TitleProvider = {
|
||||
id: "csdn",
|
||||
displayName: "CSDN",
|
||||
matches(url) {
|
||||
return isHost(url, "blog.csdn.net") || isHost(url, "csdn.net");
|
||||
},
|
||||
createRequests(_url, rawUrl) {
|
||||
return createHtmlRequest(rawUrl);
|
||||
},
|
||||
parse(response) {
|
||||
return cleanCsdnTitle(extractHtmlTitle(response.text));
|
||||
},
|
||||
};
|
||||
|
||||
const tencentCloudProvider = createHtmlTitleProvider({
|
||||
id: "tencent-cloud",
|
||||
displayName: "Tencent Cloud Developer",
|
||||
domains: ["cloud.tencent.com"],
|
||||
suffixes: ["-腾讯云开发者社区-腾讯云", "-腾讯云开发者社区", "-腾讯云"],
|
||||
});
|
||||
|
||||
const aliyunDeveloperProvider = createHtmlTitleProvider({
|
||||
id: "aliyun-developer",
|
||||
displayName: "Alibaba Cloud Developer",
|
||||
domains: ["developer.aliyun.com"],
|
||||
suffixes: ["-阿里云开发者社区", " - 阿里云开发者社区"],
|
||||
});
|
||||
|
||||
const wechatProvider = createHtmlTitleProvider({
|
||||
id: "wechat",
|
||||
displayName: "WeChat Official Accounts",
|
||||
domains: ["mp.weixin.qq.com"],
|
||||
suffixes: [" - 微信公众平台"],
|
||||
});
|
||||
|
||||
const doubanProvider = createHtmlTitleProvider({
|
||||
id: "douban",
|
||||
displayName: "Douban",
|
||||
domains: ["douban.com"],
|
||||
suffixes: [" (豆瓣)", " | 豆瓣"],
|
||||
});
|
||||
|
||||
const cnblogsProvider = createHtmlTitleProvider({
|
||||
id: "cnblogs",
|
||||
displayName: "CNBlogs",
|
||||
domains: ["cnblogs.com"],
|
||||
suffixes: [" - 博客园"],
|
||||
});
|
||||
|
||||
const segmentFaultProvider = createHtmlTitleProvider({
|
||||
id: "segmentfault",
|
||||
displayName: "SegmentFault",
|
||||
domains: ["segmentfault.com"],
|
||||
suffixes: [" - SegmentFault 思否", " - SegmentFault"],
|
||||
});
|
||||
|
||||
const jianshuProvider: TitleProvider = {
|
||||
id: "jianshu",
|
||||
displayName: "Jianshu",
|
||||
matches(url) {
|
||||
return isHost(url, "jianshu.com");
|
||||
},
|
||||
createRequests(_url, rawUrl) {
|
||||
return createHtmlRequest(rawUrl);
|
||||
},
|
||||
parse(response) {
|
||||
const title = cleanSiteSuffix(extractHtmlTitle(response.text), [" - 简书"]);
|
||||
return title === "抱歉,你访问的页面不存在。" ? null : title;
|
||||
},
|
||||
};
|
||||
|
||||
export const chineseContentProviders: readonly TitleProvider[] = [
|
||||
juejinProvider,
|
||||
csdnProvider,
|
||||
tencentCloudProvider,
|
||||
aliyunDeveloperProvider,
|
||||
wechatProvider,
|
||||
doubanProvider,
|
||||
cnblogsProvider,
|
||||
segmentFaultProvider,
|
||||
jianshuProvider,
|
||||
];
|
||||
|
||||
function createHtmlRequest(rawUrl: string) {
|
||||
return [
|
||||
{
|
||||
kind: "html",
|
||||
request: {
|
||||
url: rawUrl,
|
||||
headers: HTML_HEADERS,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
289
src/core/titleProviders/groups/codeDevelopment.ts
Normal file
289
src/core/titleProviders/groups/codeDevelopment.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import type { TitleProvider, TitleRequestSpec } from "../types.ts";
|
||||
import {
|
||||
cleanSiteSuffix,
|
||||
createHtmlTitleProvider,
|
||||
extractHtmlTitle,
|
||||
HTML_HEADERS,
|
||||
isHost,
|
||||
JSON_HEADERS,
|
||||
} from "../utils.ts";
|
||||
|
||||
const githubProvider: TitleProvider = {
|
||||
id: "github",
|
||||
displayName: "GitHub",
|
||||
matches(url) {
|
||||
return isHost(url, "github.com");
|
||||
},
|
||||
createRequests(_url, rawUrl) {
|
||||
return createHtmlRequest(rawUrl);
|
||||
},
|
||||
parse(response, spec, context) {
|
||||
const repositoryName = formatRepositoryPathTitle(spec.request.url, context.githubTitleFormat);
|
||||
if (repositoryName) {
|
||||
return repositoryName;
|
||||
}
|
||||
|
||||
return cleanSiteSuffix(extractHtmlTitle(response.text), [" · GitHub", " - GitHub"]);
|
||||
},
|
||||
};
|
||||
|
||||
const gitlabProvider: TitleProvider = {
|
||||
id: "gitlab",
|
||||
displayName: "GitLab",
|
||||
matches(url) {
|
||||
return isHost(url, "gitlab.com");
|
||||
},
|
||||
createRequests(_url, rawUrl) {
|
||||
return createHtmlRequest(rawUrl);
|
||||
},
|
||||
parse(response, spec) {
|
||||
return formatGitLabProjectPath(spec.request.url) ?? cleanSiteSuffix(extractHtmlTitle(response.text), [" · GitLab"]);
|
||||
},
|
||||
};
|
||||
|
||||
const giteeProvider: TitleProvider = {
|
||||
id: "gitee",
|
||||
displayName: "Gitee",
|
||||
matches(url) {
|
||||
return isHost(url, "gitee.com");
|
||||
},
|
||||
createRequests(_url, rawUrl) {
|
||||
return createHtmlRequest(rawUrl);
|
||||
},
|
||||
parse(response, spec) {
|
||||
return formatGiteeProjectPath(spec.request.url) ?? cleanSiteSuffix(extractHtmlTitle(response.text), [" - Gitee.com"]);
|
||||
},
|
||||
};
|
||||
|
||||
const npmProvider: TitleProvider = {
|
||||
id: "npm",
|
||||
displayName: "npm",
|
||||
matches(url) {
|
||||
return isHost(url, "npmjs.com");
|
||||
},
|
||||
createRequests(url, rawUrl) {
|
||||
const packageName = extractNpmPackageName(url);
|
||||
return packageName
|
||||
? [
|
||||
{
|
||||
kind: "npm-registry",
|
||||
request: {
|
||||
url: `https://registry.npmjs.org/${encodeURIComponent(packageName)}`,
|
||||
headers: JSON_HEADERS,
|
||||
},
|
||||
},
|
||||
...createHtmlRequest(rawUrl),
|
||||
]
|
||||
: createHtmlRequest(rawUrl);
|
||||
},
|
||||
parse(response, spec) {
|
||||
if (spec.kind === "npm-registry") {
|
||||
return parseJsonName(response.text);
|
||||
}
|
||||
|
||||
return cleanSiteSuffix(extractHtmlTitle(response.text), [" | npm"]);
|
||||
},
|
||||
};
|
||||
|
||||
const pypiProvider: TitleProvider = {
|
||||
id: "pypi",
|
||||
displayName: "PyPI",
|
||||
matches(url) {
|
||||
return isHost(url, "pypi.org");
|
||||
},
|
||||
createRequests(url, rawUrl) {
|
||||
const packageName = extractPathPart(url, /^\/project\/([^/]+)/);
|
||||
return packageName
|
||||
? [
|
||||
{
|
||||
kind: "pypi-json",
|
||||
request: {
|
||||
url: `https://pypi.org/pypi/${encodeURIComponent(packageName)}/json`,
|
||||
headers: JSON_HEADERS,
|
||||
},
|
||||
},
|
||||
...createHtmlRequest(rawUrl),
|
||||
]
|
||||
: createHtmlRequest(rawUrl);
|
||||
},
|
||||
parse(response, spec) {
|
||||
if (spec.kind === "pypi-json") {
|
||||
return parsePyPiName(response.text);
|
||||
}
|
||||
|
||||
return cleanSiteSuffix(extractHtmlTitle(response.text), [" · PyPI"]);
|
||||
},
|
||||
};
|
||||
|
||||
const cratesProvider: TitleProvider = {
|
||||
id: "crates",
|
||||
displayName: "crates.io",
|
||||
matches(url) {
|
||||
return isHost(url, "crates.io");
|
||||
},
|
||||
createRequests(_url, rawUrl) {
|
||||
return createHtmlRequest(rawUrl);
|
||||
},
|
||||
parse(response, spec) {
|
||||
return extractCrateName(new URL(spec.request.url)) ?? cleanSiteSuffix(extractHtmlTitle(response.text), [" | Rust Package Registry"]);
|
||||
},
|
||||
};
|
||||
|
||||
const goPackagesProvider = createHtmlTitleProvider({
|
||||
id: "go-packages",
|
||||
displayName: "Go Packages",
|
||||
domains: ["pkg.go.dev"],
|
||||
suffixes: [" - Go Packages"],
|
||||
});
|
||||
|
||||
const appleDeveloperProvider = createHtmlTitleProvider({
|
||||
id: "apple-developer",
|
||||
displayName: "Apple Developer",
|
||||
domains: ["developer.apple.com"],
|
||||
suffixes: [" | Apple Developer Documentation"],
|
||||
});
|
||||
|
||||
const unityProvider = createHtmlTitleProvider({
|
||||
id: "unity",
|
||||
displayName: "Unity",
|
||||
domains: ["unity.com"],
|
||||
suffixes: [" | Unity"],
|
||||
});
|
||||
|
||||
const nextjsProvider = createHtmlTitleProvider({
|
||||
id: "nextjs",
|
||||
displayName: "Next.js",
|
||||
domains: ["nextjs.org"],
|
||||
suffixes: [" | Next.js"],
|
||||
});
|
||||
|
||||
const vueProvider = createHtmlTitleProvider({
|
||||
id: "vue",
|
||||
displayName: "Vue.js",
|
||||
domains: ["vuejs.org"],
|
||||
suffixes: [" | Vue.js"],
|
||||
preferTitleTag: true,
|
||||
});
|
||||
|
||||
const nodejsProvider = createHtmlTitleProvider({
|
||||
id: "nodejs",
|
||||
displayName: "Node.js",
|
||||
domains: ["nodejs.org"],
|
||||
suffixes: [" | Node.js Learn", " | Node.js"],
|
||||
});
|
||||
|
||||
const stackOverflowProvider = createHtmlTitleProvider({
|
||||
id: "stackoverflow",
|
||||
displayName: "Stack Overflow",
|
||||
domains: ["stackoverflow.com"],
|
||||
suffixes: [" - Stack Overflow"],
|
||||
});
|
||||
|
||||
const stackExchangeProvider = createHtmlTitleProvider({
|
||||
id: "stackexchange",
|
||||
displayName: "Stack Exchange",
|
||||
domains: ["stackexchange.com", "serverfault.com", "superuser.com", "askubuntu.com"],
|
||||
suffixes: [" - Stack Exchange", " - Server Fault", " - Super User", " - Ask Ubuntu"],
|
||||
});
|
||||
|
||||
const mdnProvider = createHtmlTitleProvider({
|
||||
id: "mdn",
|
||||
displayName: "MDN",
|
||||
domains: ["developer.mozilla.org"],
|
||||
suffixes: [" | MDN", " - MDN Web Docs"],
|
||||
});
|
||||
|
||||
export const codeDevelopmentProviders: readonly TitleProvider[] = [
|
||||
githubProvider,
|
||||
gitlabProvider,
|
||||
giteeProvider,
|
||||
stackOverflowProvider,
|
||||
stackExchangeProvider,
|
||||
mdnProvider,
|
||||
npmProvider,
|
||||
pypiProvider,
|
||||
cratesProvider,
|
||||
goPackagesProvider,
|
||||
appleDeveloperProvider,
|
||||
unityProvider,
|
||||
nextjsProvider,
|
||||
vueProvider,
|
||||
nodejsProvider,
|
||||
];
|
||||
|
||||
function createHtmlRequest(rawUrl: string): TitleRequestSpec[] {
|
||||
return [
|
||||
{
|
||||
kind: "html",
|
||||
request: {
|
||||
url: rawUrl,
|
||||
headers: HTML_HEADERS,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function formatRepositoryPathTitle(
|
||||
rawUrl: string,
|
||||
format: "repository" | "owner-repository" | "github-owner-repository"
|
||||
): string | null {
|
||||
const url = new URL(rawUrl);
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
if (parts.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const repository = `${parts[0]}/${parts[1]}`;
|
||||
if (format === "repository") {
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
return format === "github-owner-repository" ? `GitHub - ${repository}` : repository;
|
||||
}
|
||||
|
||||
function formatGitLabProjectPath(rawUrl: string): string | null {
|
||||
const parts = new URL(rawUrl).pathname.split("/").filter(Boolean);
|
||||
const separatorIndex = parts.indexOf("-");
|
||||
const projectParts = separatorIndex >= 0 ? parts.slice(0, separatorIndex) : parts;
|
||||
return projectParts.length >= 2 ? projectParts.join("/") : null;
|
||||
}
|
||||
|
||||
function formatGiteeProjectPath(rawUrl: string): string | null {
|
||||
const parts = new URL(rawUrl).pathname.split("/").filter(Boolean);
|
||||
return parts.length === 2 ? `${parts[0]}/${parts[1]}` : null;
|
||||
}
|
||||
|
||||
function extractNpmPackageName(url: URL): string | null {
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
if (parts[0] !== "package" || parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parts[1].startsWith("@") && parts[2] ? `${parts[1]}/${parts[2]}` : parts[1];
|
||||
}
|
||||
|
||||
function extractPathPart(url: URL, pattern: RegExp): string | null {
|
||||
return url.pathname.match(pattern)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function parseJsonName(text: string): string | null {
|
||||
try {
|
||||
const data = JSON.parse(text) as { name?: unknown };
|
||||
return typeof data.name === "string" ? data.name : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePyPiName(text: string): string | null {
|
||||
try {
|
||||
const data = JSON.parse(text) as { info?: { name?: unknown } };
|
||||
return typeof data.info?.name === "string" ? data.info.name : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractCrateName(url: URL): string | null {
|
||||
return extractPathPart(url, /^\/crates\/([^/]+)/);
|
||||
}
|
||||
44
src/core/titleProviders/groups/communityKnowledge.ts
Normal file
44
src/core/titleProviders/groups/communityKnowledge.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { TitleProvider } from "../types.ts";
|
||||
import { cleanSiteSuffix, createHtmlTitleProvider, extractHtmlTitle, HTML_HEADERS, isHost } from "../utils.ts";
|
||||
|
||||
const redditProvider = createHtmlTitleProvider({
|
||||
id: "reddit",
|
||||
displayName: "Reddit",
|
||||
domains: ["reddit.com"],
|
||||
suffixes: [" : r/reddit.com", " - Reddit"],
|
||||
});
|
||||
|
||||
const wikipediaProvider: TitleProvider = {
|
||||
id: "wikipedia",
|
||||
displayName: "Wikipedia",
|
||||
matches(url) {
|
||||
return isHost(url, "wikipedia.org");
|
||||
},
|
||||
createRequests(_url, rawUrl) {
|
||||
return [
|
||||
{
|
||||
kind: "html",
|
||||
request: {
|
||||
url: rawUrl,
|
||||
headers: HTML_HEADERS,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
parse(response) {
|
||||
return cleanSiteSuffix(extractHtmlTitle(response.text), [" - Wikipedia", " - 维基百科,自由的百科全书"]);
|
||||
},
|
||||
fallbackTitle(url) {
|
||||
return extractWikipediaPathTitle(url);
|
||||
},
|
||||
};
|
||||
|
||||
export const communityKnowledgeProviders: readonly TitleProvider[] = [
|
||||
redditProvider,
|
||||
wikipediaProvider,
|
||||
];
|
||||
|
||||
function extractWikipediaPathTitle(url: URL): string | null {
|
||||
const match = url.pathname.match(/^\/wiki\/(.+)$/);
|
||||
return match ? decodeURIComponent(match[1]).replace(/_/g, " ") : null;
|
||||
}
|
||||
124
src/core/titleProviders/groups/entertainment.ts
Normal file
124
src/core/titleProviders/groups/entertainment.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import type { TitleProvider } from "../types.ts";
|
||||
import {
|
||||
cleanSiteSuffix,
|
||||
createHtmlTitleProvider,
|
||||
extractHtmlTitle,
|
||||
isHost,
|
||||
JSON_HEADERS,
|
||||
normalizeTitle,
|
||||
parseJsonTitle,
|
||||
} from "../utils.ts";
|
||||
|
||||
const tmdbProvider = createHtmlTitleProvider({
|
||||
id: "tmdb",
|
||||
displayName: "TMDb",
|
||||
domains: ["themoviedb.org"],
|
||||
suffixes: [" — The Movie Database (TMDB)", " - The Movie Database (TMDB)"],
|
||||
});
|
||||
|
||||
const rottenTomatoesProvider = createHtmlTitleProvider({
|
||||
id: "rotten-tomatoes",
|
||||
displayName: "Rotten Tomatoes",
|
||||
domains: ["rottentomatoes.com"],
|
||||
suffixes: [" | Rotten Tomatoes"],
|
||||
});
|
||||
|
||||
const myAnimeListProvider = createHtmlTitleProvider({
|
||||
id: "myanimelist",
|
||||
displayName: "MyAnimeList",
|
||||
domains: ["myanimelist.net"],
|
||||
suffixes: [" - MyAnimeList.net"],
|
||||
});
|
||||
|
||||
const bangumiProvider = createHtmlTitleProvider({
|
||||
id: "bangumi",
|
||||
displayName: "Bangumi",
|
||||
domains: ["bangumi.tv", "bgm.tv", "chii.in"],
|
||||
suffixes: [" | Bangumi 番组计划", " | Bangumi"],
|
||||
});
|
||||
|
||||
const letterboxdProvider: TitleProvider = {
|
||||
id: "letterboxd",
|
||||
displayName: "Letterboxd",
|
||||
matches(url) {
|
||||
return isHost(url, "letterboxd.com");
|
||||
},
|
||||
createRequests(_url, rawUrl) {
|
||||
return [
|
||||
{
|
||||
kind: "html",
|
||||
request: {
|
||||
url: rawUrl,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
parse(response) {
|
||||
return cleanLetterboxdTitle(extractHtmlTitle(response.text));
|
||||
},
|
||||
};
|
||||
|
||||
const spotifyProvider = createOEmbedProvider({
|
||||
id: "spotify",
|
||||
displayName: "Spotify",
|
||||
domains: ["open.spotify.com"],
|
||||
endpoint: "https://open.spotify.com/oembed",
|
||||
});
|
||||
|
||||
const dailymotionProvider = createOEmbedProvider({
|
||||
id: "dailymotion",
|
||||
displayName: "Dailymotion",
|
||||
domains: ["dailymotion.com", "dai.ly"],
|
||||
endpoint: "https://www.dailymotion.com/services/oembed",
|
||||
});
|
||||
|
||||
export const entertainmentProviders: readonly TitleProvider[] = [
|
||||
tmdbProvider,
|
||||
letterboxdProvider,
|
||||
rottenTomatoesProvider,
|
||||
myAnimeListProvider,
|
||||
bangumiProvider,
|
||||
spotifyProvider,
|
||||
dailymotionProvider,
|
||||
];
|
||||
|
||||
function createOEmbedProvider(options: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
domains: string[];
|
||||
endpoint: string;
|
||||
}): TitleProvider {
|
||||
return {
|
||||
id: options.id,
|
||||
displayName: options.displayName,
|
||||
matches(url) {
|
||||
return options.domains.some((domain) => isHost(url, domain));
|
||||
},
|
||||
createRequests(_url, rawUrl) {
|
||||
return [
|
||||
{
|
||||
kind: "oembed",
|
||||
request: {
|
||||
url: `${options.endpoint}?url=${encodeURIComponent(rawUrl)}`,
|
||||
headers: JSON_HEADERS,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
parse(response) {
|
||||
return parseJsonTitle(response.text);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cleanLetterboxdTitle(title: string | null): string | null {
|
||||
let result = cleanSiteSuffix(title?.replace(/\u200e/g, "") ?? null, [" • Letterboxd"]);
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
result = result.split(" directed by ")[0]?.trim() ?? result;
|
||||
result = result.split(" • ")[0]?.trim() ?? result;
|
||||
|
||||
return normalizeTitle(result);
|
||||
}
|
||||
177
src/core/titleProviders/groups/videoCreation.ts
Normal file
177
src/core/titleProviders/groups/videoCreation.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import type { TitleProvider, TitleRequestSpec } from "../types.ts";
|
||||
import {
|
||||
cleanBilibiliTitle,
|
||||
cleanFabTitle,
|
||||
cleanSteamTitle,
|
||||
cleanYouTubeTitle,
|
||||
extractHtmlTitle,
|
||||
HTML_HEADERS,
|
||||
isHost,
|
||||
JSON_HEADERS,
|
||||
parseBilibiliApiTitle,
|
||||
parseJsonTitle,
|
||||
} from "../utils.ts";
|
||||
|
||||
const FAB_HTML_HEADERS = {
|
||||
...HTML_HEADERS,
|
||||
"User-Agent": "facebookexternalhit/1.1",
|
||||
};
|
||||
|
||||
const bilibiliProvider: TitleProvider = {
|
||||
id: "bilibili",
|
||||
displayName: "bilibili",
|
||||
matches(url) {
|
||||
return isHost(url, "bilibili.com") || isHost(url, "b23.tv");
|
||||
},
|
||||
createRequests(url, rawUrl) {
|
||||
const bvid = extractBilibiliBvid(url);
|
||||
const requests: TitleRequestSpec[] = [];
|
||||
if (bvid) {
|
||||
requests.push({
|
||||
kind: "bilibili-api",
|
||||
request: {
|
||||
url: `https://api.bilibili.com/x/web-interface/view?bvid=${encodeURIComponent(bvid)}`,
|
||||
headers: JSON_HEADERS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
requests.push({
|
||||
kind: "html",
|
||||
request: {
|
||||
url: rawUrl,
|
||||
headers: HTML_HEADERS,
|
||||
},
|
||||
});
|
||||
return requests;
|
||||
},
|
||||
parse(response, spec) {
|
||||
if (spec.kind === "bilibili-api") {
|
||||
return cleanBilibiliTitle(parseBilibiliApiTitle(response.text));
|
||||
}
|
||||
|
||||
return cleanBilibiliTitle(extractHtmlTitle(response.text));
|
||||
},
|
||||
};
|
||||
|
||||
const youtubeProvider: TitleProvider = {
|
||||
id: "youtube",
|
||||
displayName: "YouTube",
|
||||
matches(url) {
|
||||
return isHost(url, "youtube.com") || isHost(url, "youtu.be");
|
||||
},
|
||||
createRequests(_url, rawUrl) {
|
||||
return [
|
||||
{
|
||||
kind: "youtube-oembed",
|
||||
request: {
|
||||
url: `https://www.youtube.com/oembed?format=json&url=${encodeURIComponent(rawUrl)}`,
|
||||
headers: JSON_HEADERS,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "html",
|
||||
request: {
|
||||
url: rawUrl,
|
||||
headers: HTML_HEADERS,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
parse(response, spec) {
|
||||
if (spec.kind === "youtube-oembed") {
|
||||
return cleanYouTubeTitle(parseJsonTitle(response.text));
|
||||
}
|
||||
|
||||
return cleanYouTubeTitle(extractHtmlTitle(response.text));
|
||||
},
|
||||
};
|
||||
|
||||
const fabProvider: TitleProvider = {
|
||||
id: "fab",
|
||||
displayName: "Fab",
|
||||
matches(url) {
|
||||
return isHost(url, "fab.com");
|
||||
},
|
||||
createRequests(_url, rawUrl) {
|
||||
return [
|
||||
{
|
||||
kind: "html",
|
||||
request: {
|
||||
url: rawUrl,
|
||||
headers: FAB_HTML_HEADERS,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
parse(response) {
|
||||
return cleanFabTitle(extractHtmlTitle(response.text));
|
||||
},
|
||||
};
|
||||
|
||||
const steamProvider: TitleProvider = {
|
||||
id: "steam",
|
||||
displayName: "Steam",
|
||||
matches(url) {
|
||||
return isHost(url, "store.steampowered.com") || isHost(url, "steamcommunity.com");
|
||||
},
|
||||
createRequests(url, rawUrl) {
|
||||
const appId = extractSteamAppId(url);
|
||||
const requests: TitleRequestSpec[] = [];
|
||||
if (appId) {
|
||||
requests.push({
|
||||
kind: "steam-appdetails",
|
||||
request: {
|
||||
url: `https://store.steampowered.com/api/appdetails?appids=${encodeURIComponent(appId)}&filters=basic&l=${getSteamLanguage()}`,
|
||||
headers: JSON_HEADERS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
requests.push({
|
||||
kind: "html",
|
||||
request: {
|
||||
url: rawUrl,
|
||||
headers: HTML_HEADERS,
|
||||
},
|
||||
});
|
||||
return requests;
|
||||
},
|
||||
parse(response, spec) {
|
||||
if (spec.kind === "steam-appdetails") {
|
||||
return parseSteamAppDetailsTitle(response.text);
|
||||
}
|
||||
|
||||
return cleanSteamTitle(extractHtmlTitle(response.text));
|
||||
},
|
||||
};
|
||||
|
||||
export const videoCreationProviders: readonly TitleProvider[] = [
|
||||
bilibiliProvider,
|
||||
youtubeProvider,
|
||||
fabProvider,
|
||||
steamProvider,
|
||||
];
|
||||
|
||||
function extractBilibiliBvid(url: URL): string | null {
|
||||
return url.pathname.match(/\/video\/(BV[a-zA-Z0-9]+)/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function extractSteamAppId(url: URL): string | null {
|
||||
return url.pathname.match(/^\/app\/(\d+)(?:\/|$)/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function parseSteamAppDetailsTitle(text: string): string | null {
|
||||
try {
|
||||
const data = JSON.parse(text) as Record<string, { success?: boolean; data?: { name?: unknown } }>;
|
||||
const app = Object.values(data)[0];
|
||||
return app?.success && typeof app.data?.name === "string" ? app.data.name : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSteamLanguage(): string {
|
||||
const language = typeof activeWindow === "undefined" ? "" : activeWindow.navigator.language.toLowerCase();
|
||||
return language.startsWith("zh") ? "schinese" : "english";
|
||||
}
|
||||
45
src/core/titleProviders/providers.ts
Normal file
45
src/core/titleProviders/providers.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { SupportedSiteGroup, TitleProvider } from "./types.ts";
|
||||
import { chineseContentProviders } from "./groups/chineseContent.ts";
|
||||
import { codeDevelopmentProviders } from "./groups/codeDevelopment.ts";
|
||||
import { communityKnowledgeProviders } from "./groups/communityKnowledge.ts";
|
||||
import { entertainmentProviders } from "./groups/entertainment.ts";
|
||||
import { videoCreationProviders } from "./groups/videoCreation.ts";
|
||||
|
||||
export const SUPPORTED_SITE_GROUPS: readonly SupportedSiteGroup[] = [
|
||||
{
|
||||
id: "video-creation",
|
||||
zhName: "视频与创作",
|
||||
enName: "Video and creation",
|
||||
providers: videoCreationProviders,
|
||||
},
|
||||
{
|
||||
id: "entertainment",
|
||||
zhName: "娱乐",
|
||||
enName: "Entertainment",
|
||||
providers: entertainmentProviders,
|
||||
},
|
||||
{
|
||||
id: "code-development",
|
||||
zhName: "代码与开发",
|
||||
enName: "Code and development",
|
||||
providers: codeDevelopmentProviders,
|
||||
},
|
||||
{
|
||||
id: "community-knowledge",
|
||||
zhName: "社区与百科",
|
||||
enName: "Community and knowledge",
|
||||
providers: communityKnowledgeProviders,
|
||||
},
|
||||
{
|
||||
id: "chinese-content",
|
||||
zhName: "中文内容",
|
||||
enName: "Chinese content",
|
||||
providers: chineseContentProviders,
|
||||
},
|
||||
];
|
||||
|
||||
export const TITLE_PROVIDERS: readonly TitleProvider[] = SUPPORTED_SITE_GROUPS.flatMap(
|
||||
(group) => group.providers
|
||||
);
|
||||
|
||||
export const SUPPORTED_SITE_NAMES = TITLE_PROVIDERS.map((provider) => provider.displayName);
|
||||
39
src/core/titleProviders/types.ts
Normal file
39
src/core/titleProviders/types.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
export interface TitleRequestInput {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TitleRequestResponse {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export type TitleRequest = (request: TitleRequestInput) => Promise<TitleRequestResponse>;
|
||||
|
||||
export type GitHubTitleFormat = "repository" | "owner-repository" | "github-owner-repository";
|
||||
|
||||
export interface TitleResolveContext {
|
||||
githubTitleFormat: GitHubTitleFormat;
|
||||
}
|
||||
|
||||
export interface TitleRequestSpec {
|
||||
kind: string;
|
||||
request: TitleRequestInput;
|
||||
}
|
||||
|
||||
export interface TitleProvider {
|
||||
id: string;
|
||||
displayName: string;
|
||||
matches(url: URL): boolean;
|
||||
createRequests(url: URL, rawUrl: string): TitleRequestSpec[];
|
||||
parse(response: TitleRequestResponse, spec: TitleRequestSpec, context: TitleResolveContext): string | null;
|
||||
fallbackTitle?(url: URL, rawUrl: string, context: TitleResolveContext): string | null;
|
||||
}
|
||||
|
||||
export interface SupportedSiteGroup {
|
||||
id: string;
|
||||
zhName: string;
|
||||
enName: string;
|
||||
providers: readonly TitleProvider[];
|
||||
}
|
||||
273
src/core/titleProviders/utils.ts
Normal file
273
src/core/titleProviders/utils.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import type { TitleProvider } from "./types.ts";
|
||||
|
||||
export const JSON_HEADERS = {
|
||||
Accept: "application/json,text/plain,*/*",
|
||||
};
|
||||
|
||||
export const HTML_HEADERS = {
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
};
|
||||
|
||||
interface HtmlTitleProviderOptions {
|
||||
id: string;
|
||||
displayName: string;
|
||||
domains: string[];
|
||||
suffixes: string[];
|
||||
preferTitleTag?: boolean;
|
||||
}
|
||||
|
||||
export function createHtmlTitleProvider(options: HtmlTitleProviderOptions): TitleProvider {
|
||||
return {
|
||||
id: options.id,
|
||||
displayName: options.displayName,
|
||||
matches(url) {
|
||||
return options.domains.some((domain) => isHost(url, domain));
|
||||
},
|
||||
createRequests(_url, rawUrl) {
|
||||
return [
|
||||
{
|
||||
kind: "html",
|
||||
request: {
|
||||
url: rawUrl,
|
||||
headers: HTML_HEADERS,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
parse(response) {
|
||||
const title = options.preferTitleTag
|
||||
? extractTitleTag(response.text) ?? extractHtmlTitle(response.text)
|
||||
: extractHtmlTitle(response.text);
|
||||
return cleanSiteSuffix(title, options.suffixes);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function extractHtmlTitle(html: string): string | null {
|
||||
return (
|
||||
extractMetaContent(html, ["og:title"]) ??
|
||||
extractMetaContent(html, ["twitter:title"]) ??
|
||||
extractTitleTag(html)
|
||||
);
|
||||
}
|
||||
|
||||
export function extractGenericHtmlTitle(html: string, url: URL): string | null {
|
||||
const socialTitle = extractMetaContent(html, ["og:title"]) ?? extractMetaContent(html, ["twitter:title"]);
|
||||
if (socialTitle) {
|
||||
return normalizeGenericTitle(socialTitle);
|
||||
}
|
||||
|
||||
return cleanGenericTitle(extractTitleTag(html), url);
|
||||
}
|
||||
|
||||
export function cleanBilibiliTitle(title: string | null): string | null {
|
||||
return cleanSiteSuffix(title, [
|
||||
"_哔哩哔哩_bilibili",
|
||||
"-哔哩哔哩_Bilibili",
|
||||
" - 哔哩哔哩",
|
||||
" - bilibili",
|
||||
]);
|
||||
}
|
||||
|
||||
export function cleanYouTubeTitle(title: string | null): string | null {
|
||||
return cleanSiteSuffix(title, [" - YouTube"]);
|
||||
}
|
||||
|
||||
export function cleanFabTitle(title: string | null): string | null {
|
||||
return cleanSiteSuffix(title, [" | Fab", " - Fab"]);
|
||||
}
|
||||
|
||||
export function cleanSteamTitle(title: string | null): string | null {
|
||||
let result = cleanSiteSuffix(title, [" on Steam", " :: Steam Community"]);
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
result = result
|
||||
.replace(/^Save\s+\d+%\s+on\s+/i, "")
|
||||
.replace(/^在\s*Steam\s*上购买\s*/i, "")
|
||||
.replace(/\s+立省\s*\d+%$/i, "")
|
||||
.trim();
|
||||
|
||||
return normalizeTitle(result);
|
||||
}
|
||||
|
||||
export function cleanCsdnTitle(title: string | null): string | null {
|
||||
let result = cleanSiteSuffix(title, ["-CSDN博客", "_csdn", " - CSDN博客"]);
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seoTail = result.match(/^(.+)_([a-z0-9][a-z0-9+.# -]{0,48})$/i);
|
||||
if (seoTail && /\s/.test(seoTail[2])) {
|
||||
result = seoTail[1].trim();
|
||||
}
|
||||
|
||||
return normalizeTitle(result);
|
||||
}
|
||||
|
||||
export function parseBilibiliApiTitle(text: string): string | null {
|
||||
try {
|
||||
const data = JSON.parse(text) as { data?: { title?: unknown } };
|
||||
return typeof data.data?.title === "string" ? data.data.title : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseJsonTitle(text: string): string | null {
|
||||
try {
|
||||
const data = JSON.parse(text) as { title?: unknown };
|
||||
return typeof data.title === "string" ? data.title : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanSiteSuffix(title: string | null, suffixes: string[]): string | null {
|
||||
let result = normalizeTitle(title);
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const suffix of suffixes) {
|
||||
if (result.toLowerCase().endsWith(suffix.toLowerCase())) {
|
||||
result = result.slice(0, -suffix.length).trim();
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeTitle(result);
|
||||
}
|
||||
|
||||
export function normalizeTitle(title: string | null): string | null {
|
||||
const normalized = title?.replace(/\s+/g, " ").trim();
|
||||
if (!normalized || !isTitleUsable(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeGenericTitle(title: string | null): string | null {
|
||||
const normalized = normalizeTitle(title);
|
||||
if (!normalized || isUrlLikeTitle(normalized) || normalized.length > 120) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function isHost(url: URL, domain: string): boolean {
|
||||
return url.hostname === domain || url.hostname.endsWith(`.${domain}`);
|
||||
}
|
||||
|
||||
export function parseHttpUrl(value: string): URL | null {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractMetaContent(html: string, keys: string[]): string | null {
|
||||
const metaTags = html.match(/<meta\b[^>]*>/gi) ?? [];
|
||||
const normalizedKeys = new Set(keys.map((key) => key.toLowerCase()));
|
||||
|
||||
for (const tag of metaTags) {
|
||||
const property = getAttribute(tag, "property")?.toLowerCase();
|
||||
const name = getAttribute(tag, "name")?.toLowerCase();
|
||||
if (!property && !name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizedKeys.has(property ?? "") || normalizedKeys.has(name ?? "")) {
|
||||
const content = getAttribute(tag, "content");
|
||||
if (content) {
|
||||
return decodeHtml(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTitleTag(html: string): string | null {
|
||||
const match = html.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
|
||||
return match ? decodeHtml(match[1]) : null;
|
||||
}
|
||||
|
||||
function getAttribute(tag: string, name: string): string | null {
|
||||
const pattern = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i");
|
||||
const match = tag.match(pattern);
|
||||
return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
|
||||
}
|
||||
|
||||
function decodeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&#(\d+);/g, (_match, code: string) => String.fromCodePoint(Number.parseInt(code, 10)))
|
||||
.replace(/&#x([a-f0-9]+);/gi, (_match, code: string) => String.fromCodePoint(Number.parseInt(code, 16)))
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function cleanGenericTitle(title: string | null, url: URL): string | null {
|
||||
const normalized = normalizeGenericTitle(title);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const delimiterPattern = /\s(?:[-|—–_])\s|_/g;
|
||||
const matches = [...normalized.matchAll(delimiterPattern)];
|
||||
if (matches.length === 0) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const delimiterIndex = matches[0].index ?? -1;
|
||||
if (delimiterIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const suffix = normalized.slice(delimiterIndex + matches[0][0].length).trim();
|
||||
const prefix = normalized.slice(0, delimiterIndex).trim();
|
||||
if (!prefix || !isHostRelatedSuffix(suffix, url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeGenericTitle(prefix);
|
||||
}
|
||||
|
||||
function isHostRelatedSuffix(suffix: string, url: URL): boolean {
|
||||
const normalizedSuffix = suffix.toLowerCase().replace(/\s+/g, "");
|
||||
const hostParts = url.hostname.toLowerCase().replace(/^www\./, "").split(".");
|
||||
const domainName = hostParts.length > 1 ? hostParts[hostParts.length - 2] : hostParts[0];
|
||||
return Boolean(domainName && normalizedSuffix.includes(domainName));
|
||||
}
|
||||
|
||||
function isTitleUsable(title: string): boolean {
|
||||
const normalized = title.toLowerCase();
|
||||
return !(
|
||||
normalized === "知乎" ||
|
||||
normalized === "just a moment..." ||
|
||||
normalized === "access denied" ||
|
||||
normalized === "client challenge" ||
|
||||
normalized === "forbidden" ||
|
||||
normalized === "error" ||
|
||||
normalized === "not found" ||
|
||||
normalized === "reddit - please wait for verification" ||
|
||||
normalized.includes("please wait for verification") ||
|
||||
normalized.includes("attention required! | cloudflare")
|
||||
);
|
||||
}
|
||||
|
||||
function isUrlLikeTitle(title: string): boolean {
|
||||
return /^https?:\/\//i.test(title) || /^[a-z0-9.-]+\.[a-z]{2,}(?:\/.*)?$/i.test(title);
|
||||
}
|
||||
|
|
@ -1,246 +1,41 @@
|
|||
export interface TitleRequestInput {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
import {
|
||||
SUPPORTED_SITE_GROUPS,
|
||||
SUPPORTED_SITE_NAMES,
|
||||
TITLE_PROVIDERS,
|
||||
} from "./titleProviders/providers.ts";
|
||||
import type {
|
||||
GitHubTitleFormat,
|
||||
SupportedSiteGroup,
|
||||
TitleRequest,
|
||||
TitleRequestInput,
|
||||
TitleRequestResponse,
|
||||
TitleResolveContext,
|
||||
} from "./titleProviders/types.ts";
|
||||
import { extractGenericHtmlTitle, HTML_HEADERS, normalizeTitle, parseHttpUrl } from "./titleProviders/utils.ts";
|
||||
|
||||
export interface TitleRequestResponse {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
text: string;
|
||||
}
|
||||
export {
|
||||
cleanBilibiliTitle,
|
||||
cleanCsdnTitle,
|
||||
cleanFabTitle,
|
||||
cleanSteamTitle,
|
||||
cleanYouTubeTitle,
|
||||
extractGenericHtmlTitle,
|
||||
extractHtmlTitle,
|
||||
} from "./titleProviders/utils.ts";
|
||||
|
||||
export type TitleRequest = (request: TitleRequestInput) => Promise<TitleRequestResponse>;
|
||||
|
||||
interface TitleRequestSpec {
|
||||
kind: string;
|
||||
request: TitleRequestInput;
|
||||
}
|
||||
|
||||
interface TitleProvider {
|
||||
id: string;
|
||||
displayName: string;
|
||||
matches(url: URL): boolean;
|
||||
createRequests(url: URL, rawUrl: string): TitleRequestSpec[];
|
||||
parse(response: TitleRequestResponse, spec: TitleRequestSpec): string | null;
|
||||
}
|
||||
export { SUPPORTED_SITE_GROUPS, SUPPORTED_SITE_NAMES };
|
||||
export type { GitHubTitleFormat, SupportedSiteGroup, TitleRequest, TitleRequestInput, TitleRequestResponse };
|
||||
|
||||
export interface ResolveSupportedSiteTitleOptions {
|
||||
request: TitleRequest;
|
||||
timeoutMs: number;
|
||||
githubTitleFormat?: GitHubTitleFormat;
|
||||
fetchGenericSiteTitle?: boolean;
|
||||
}
|
||||
|
||||
export const SUPPORTED_SITE_NAMES = [
|
||||
"bilibili",
|
||||
"YouTube",
|
||||
"Fab",
|
||||
"GitHub",
|
||||
"Stack Overflow",
|
||||
"Stack Exchange",
|
||||
"Reddit",
|
||||
"Wikipedia",
|
||||
"Steam",
|
||||
"MDN",
|
||||
"npm",
|
||||
"Zhihu",
|
||||
"Juejin",
|
||||
"CSDN",
|
||||
"WeChat Official Accounts",
|
||||
"Douban",
|
||||
"CNBlogs",
|
||||
] as const;
|
||||
|
||||
const JSON_HEADERS = {
|
||||
Accept: "application/json,text/plain,*/*",
|
||||
};
|
||||
|
||||
const HTML_HEADERS = {
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
};
|
||||
|
||||
const FAB_HTML_HEADERS = {
|
||||
...HTML_HEADERS,
|
||||
"User-Agent": "facebookexternalhit/1.1",
|
||||
};
|
||||
|
||||
const PROVIDERS: TitleProvider[] = [
|
||||
{
|
||||
id: "bilibili",
|
||||
displayName: "bilibili",
|
||||
matches(url) {
|
||||
return isHost(url, "bilibili.com") || isHost(url, "b23.tv");
|
||||
},
|
||||
createRequests(url, rawUrl) {
|
||||
const bvid = extractBilibiliBvid(url);
|
||||
const requests: TitleRequestSpec[] = [];
|
||||
if (bvid) {
|
||||
requests.push({
|
||||
kind: "bilibili-api",
|
||||
request: {
|
||||
url: `https://api.bilibili.com/x/web-interface/view?bvid=${encodeURIComponent(bvid)}`,
|
||||
headers: JSON_HEADERS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
requests.push({
|
||||
kind: "html",
|
||||
request: {
|
||||
url: rawUrl,
|
||||
headers: HTML_HEADERS,
|
||||
},
|
||||
});
|
||||
return requests;
|
||||
},
|
||||
parse(response, spec) {
|
||||
if (spec.kind === "bilibili-api") {
|
||||
return cleanBilibiliTitle(parseBilibiliApiTitle(response.text));
|
||||
}
|
||||
|
||||
return cleanBilibiliTitle(extractHtmlTitle(response.text));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "youtube",
|
||||
displayName: "YouTube",
|
||||
matches(url) {
|
||||
return isHost(url, "youtube.com") || isHost(url, "youtu.be");
|
||||
},
|
||||
createRequests(_url, rawUrl) {
|
||||
return [
|
||||
{
|
||||
kind: "youtube-oembed",
|
||||
request: {
|
||||
url: `https://www.youtube.com/oembed?format=json&url=${encodeURIComponent(rawUrl)}`,
|
||||
headers: JSON_HEADERS,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "html",
|
||||
request: {
|
||||
url: rawUrl,
|
||||
headers: HTML_HEADERS,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
parse(response, spec) {
|
||||
if (spec.kind === "youtube-oembed") {
|
||||
return cleanYouTubeTitle(parseJsonTitle(response.text));
|
||||
}
|
||||
|
||||
return cleanYouTubeTitle(extractHtmlTitle(response.text));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "fab",
|
||||
displayName: "Fab",
|
||||
matches(url) {
|
||||
return isHost(url, "fab.com");
|
||||
},
|
||||
createRequests(_url, rawUrl) {
|
||||
return [
|
||||
{
|
||||
kind: "html",
|
||||
request: {
|
||||
url: rawUrl,
|
||||
headers: FAB_HTML_HEADERS,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
parse(response) {
|
||||
return cleanFabTitle(extractHtmlTitle(response.text));
|
||||
},
|
||||
},
|
||||
createHtmlTitleProvider({
|
||||
id: "github",
|
||||
displayName: "GitHub",
|
||||
domains: ["github.com"],
|
||||
suffixes: [" · GitHub", " - GitHub"],
|
||||
}),
|
||||
createHtmlTitleProvider({
|
||||
id: "stackoverflow",
|
||||
displayName: "Stack Overflow",
|
||||
domains: ["stackoverflow.com"],
|
||||
suffixes: [" - Stack Overflow"],
|
||||
}),
|
||||
createHtmlTitleProvider({
|
||||
id: "stackexchange",
|
||||
displayName: "Stack Exchange",
|
||||
domains: ["stackexchange.com", "serverfault.com", "superuser.com", "askubuntu.com"],
|
||||
suffixes: [" - Stack Exchange", " - Server Fault", " - Super User", " - Ask Ubuntu"],
|
||||
}),
|
||||
createHtmlTitleProvider({
|
||||
id: "reddit",
|
||||
displayName: "Reddit",
|
||||
domains: ["reddit.com"],
|
||||
suffixes: [" : r/reddit.com", " - Reddit"],
|
||||
}),
|
||||
createHtmlTitleProvider({
|
||||
id: "wikipedia",
|
||||
displayName: "Wikipedia",
|
||||
domains: ["wikipedia.org"],
|
||||
suffixes: [" - Wikipedia", " - 维基百科,自由的百科全书"],
|
||||
}),
|
||||
createHtmlTitleProvider({
|
||||
id: "steam",
|
||||
displayName: "Steam",
|
||||
domains: ["store.steampowered.com", "steamcommunity.com"],
|
||||
suffixes: [" on Steam", " :: Steam Community"],
|
||||
}),
|
||||
createHtmlTitleProvider({
|
||||
id: "mdn",
|
||||
displayName: "MDN",
|
||||
domains: ["developer.mozilla.org"],
|
||||
suffixes: [" | MDN", " - MDN Web Docs"],
|
||||
}),
|
||||
createHtmlTitleProvider({
|
||||
id: "npm",
|
||||
displayName: "npm",
|
||||
domains: ["npmjs.com"],
|
||||
suffixes: [" | npm"],
|
||||
}),
|
||||
createHtmlTitleProvider({
|
||||
id: "zhihu",
|
||||
displayName: "Zhihu",
|
||||
domains: ["zhihu.com", "zhuanlan.zhihu.com"],
|
||||
suffixes: [" - 知乎", " - 知乎专栏"],
|
||||
}),
|
||||
createHtmlTitleProvider({
|
||||
id: "juejin",
|
||||
displayName: "Juejin",
|
||||
domains: ["juejin.cn"],
|
||||
suffixes: [" - 掘金"],
|
||||
}),
|
||||
createHtmlTitleProvider({
|
||||
id: "csdn",
|
||||
displayName: "CSDN",
|
||||
domains: ["blog.csdn.net", "csdn.net"],
|
||||
suffixes: ["-CSDN博客", "_csdn", " - CSDN博客"],
|
||||
}),
|
||||
createHtmlTitleProvider({
|
||||
id: "wechat",
|
||||
displayName: "WeChat Official Accounts",
|
||||
domains: ["mp.weixin.qq.com"],
|
||||
suffixes: [" - 微信公众平台"],
|
||||
}),
|
||||
createHtmlTitleProvider({
|
||||
id: "douban",
|
||||
displayName: "Douban",
|
||||
domains: ["douban.com"],
|
||||
suffixes: [" (豆瓣)", " | 豆瓣"],
|
||||
}),
|
||||
createHtmlTitleProvider({
|
||||
id: "cnblogs",
|
||||
displayName: "CNBlogs",
|
||||
domains: ["cnblogs.com"],
|
||||
suffixes: [" - 博客园"],
|
||||
}),
|
||||
];
|
||||
|
||||
export function getSupportedTitleProvider(url: string): string | null {
|
||||
const parsedUrl = parseHttpUrl(url);
|
||||
return parsedUrl ? PROVIDERS.find((provider) => provider.matches(parsedUrl))?.displayName ?? null : null;
|
||||
return parsedUrl ? TITLE_PROVIDERS.find((provider) => provider.matches(parsedUrl))?.displayName ?? null : null;
|
||||
}
|
||||
|
||||
export async function resolveSupportedSiteTitle(
|
||||
|
|
@ -252,13 +47,27 @@ export async function resolveSupportedSiteTitle(
|
|||
return null;
|
||||
}
|
||||
|
||||
const provider = PROVIDERS.find((candidate) => candidate.matches(parsedUrl));
|
||||
if (!provider) {
|
||||
const provider = TITLE_PROVIDERS.find((candidate) => candidate.matches(parsedUrl));
|
||||
if (!provider && !options.fetchGenericSiteTitle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const context: TitleResolveContext = {
|
||||
githubTitleFormat: options.githubTitleFormat ?? "owner-repository",
|
||||
};
|
||||
|
||||
const deadline = Date.now() + options.timeoutMs;
|
||||
for (const spec of provider.createRequests(parsedUrl, url)) {
|
||||
const requestSpecs = provider?.createRequests(parsedUrl, url) ?? [
|
||||
{
|
||||
kind: "generic-html",
|
||||
request: {
|
||||
url,
|
||||
headers: HTML_HEADERS,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const spec of requestSpecs) {
|
||||
const remainingMs = deadline - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
return null;
|
||||
|
|
@ -269,169 +78,15 @@ export async function resolveSupportedSiteTitle(
|
|||
continue;
|
||||
}
|
||||
|
||||
const title = normalizeTitle(provider.parse(response, spec));
|
||||
const title = normalizeTitle(
|
||||
provider ? provider.parse(response, spec, context) : extractGenericHtmlTitle(response.text, parsedUrl)
|
||||
);
|
||||
if (title) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractHtmlTitle(html: string): string | null {
|
||||
return (
|
||||
extractMetaContent(html, ["og:title"]) ??
|
||||
extractMetaContent(html, ["twitter:title"]) ??
|
||||
extractTitleTag(html)
|
||||
);
|
||||
}
|
||||
|
||||
export function cleanBilibiliTitle(title: string | null): string | null {
|
||||
return cleanSiteSuffix(title, [
|
||||
"_哔哩哔哩_bilibili",
|
||||
"-哔哩哔哩_Bilibili",
|
||||
" - 哔哩哔哩",
|
||||
" - bilibili",
|
||||
]);
|
||||
}
|
||||
|
||||
export function cleanYouTubeTitle(title: string | null): string | null {
|
||||
return cleanSiteSuffix(title, [" - YouTube"]);
|
||||
}
|
||||
|
||||
export function cleanFabTitle(title: string | null): string | null {
|
||||
return cleanSiteSuffix(title, [" | Fab", " - Fab"]);
|
||||
}
|
||||
|
||||
interface HtmlTitleProviderOptions {
|
||||
id: string;
|
||||
displayName: string;
|
||||
domains: string[];
|
||||
suffixes: string[];
|
||||
}
|
||||
|
||||
function createHtmlTitleProvider(options: HtmlTitleProviderOptions): TitleProvider {
|
||||
return {
|
||||
id: options.id,
|
||||
displayName: options.displayName,
|
||||
matches(url) {
|
||||
return options.domains.some((domain) => isHost(url, domain));
|
||||
},
|
||||
createRequests(_url, rawUrl) {
|
||||
return [
|
||||
{
|
||||
kind: "html",
|
||||
request: {
|
||||
url: rawUrl,
|
||||
headers: HTML_HEADERS,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
parse(response) {
|
||||
return cleanSiteSuffix(extractHtmlTitle(response.text), options.suffixes);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extractBilibiliBvid(url: URL): string | null {
|
||||
return url.pathname.match(/\/video\/(BV[a-zA-Z0-9]+)/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function parseBilibiliApiTitle(text: string): string | null {
|
||||
try {
|
||||
const data = JSON.parse(text) as { data?: { title?: unknown } };
|
||||
return typeof data.data?.title === "string" ? data.data.title : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonTitle(text: string): string | null {
|
||||
try {
|
||||
const data = JSON.parse(text) as { title?: unknown };
|
||||
return typeof data.title === "string" ? data.title : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractMetaContent(html: string, keys: string[]): string | null {
|
||||
const metaTags = html.match(/<meta\b[^>]*>/gi) ?? [];
|
||||
const normalizedKeys = new Set(keys.map((key) => key.toLowerCase()));
|
||||
|
||||
for (const tag of metaTags) {
|
||||
const property = getAttribute(tag, "property")?.toLowerCase();
|
||||
const name = getAttribute(tag, "name")?.toLowerCase();
|
||||
if (!property && !name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizedKeys.has(property ?? "") || normalizedKeys.has(name ?? "")) {
|
||||
const content = getAttribute(tag, "content");
|
||||
if (content) {
|
||||
return decodeHtml(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTitleTag(html: string): string | null {
|
||||
const match = html.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
|
||||
return match ? decodeHtml(match[1]) : null;
|
||||
}
|
||||
|
||||
function getAttribute(tag: string, name: string): string | null {
|
||||
const pattern = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i");
|
||||
const match = tag.match(pattern);
|
||||
return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
|
||||
}
|
||||
|
||||
function cleanSiteSuffix(title: string | null, suffixes: string[]): string | null {
|
||||
let result = normalizeTitle(title);
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const suffix of suffixes) {
|
||||
if (result.toLowerCase().endsWith(suffix.toLowerCase())) {
|
||||
result = result.slice(0, -suffix.length).trim();
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeTitle(result);
|
||||
}
|
||||
|
||||
function normalizeTitle(title: string | null): string | null {
|
||||
const normalized = title?.replace(/\s+/g, " ").trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function decodeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&#(\d+);/g, (_match, code: string) => String.fromCodePoint(Number.parseInt(code, 10)))
|
||||
.replace(/&#x([a-f0-9]+);/gi, (_match, code: string) => String.fromCodePoint(Number.parseInt(code, 16)))
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function isHost(url: URL, domain: string): boolean {
|
||||
return url.hostname === domain || url.hostname.endsWith(`.${domain}`);
|
||||
}
|
||||
|
||||
function parseHttpUrl(value: string): URL | null {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return normalizeTitle(provider?.fallbackTitle?.(parsedUrl, url, context) ?? null);
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
BUILTIN_TRUSTED_IMAGE_SOURCES,
|
||||
type AutoPasteLinkSettings,
|
||||
type TrustedImageSource,
|
||||
type TrustedMediaSource,
|
||||
} from "../settings/pluginSettings.ts";
|
||||
|
||||
export type UrlKind = "normal-link" | "image-link" | "video-link" | "unsupported";
|
||||
|
|
@ -53,11 +53,11 @@ function isImageUrl(rawUrl: string, parsedUrl: URL, settings: AutoPasteLinkSetti
|
|||
return true;
|
||||
}
|
||||
|
||||
if (isTrustedImageSource(parsedUrl, BUILTIN_TRUSTED_IMAGE_SOURCES)) {
|
||||
if (isTrustedMediaSource(parsedUrl, BUILTIN_TRUSTED_IMAGE_SOURCES)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isTrustedImageSource(parsedUrl, settings.trustedImageSources)) {
|
||||
if (isTrustedMediaSource(parsedUrl, settings.trustedImageSources)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ function isImageUrl(rawUrl: string, parsedUrl: URL, settings: AutoPasteLinkSetti
|
|||
});
|
||||
}
|
||||
|
||||
function isTrustedImageSource(parsedUrl: URL, sources: TrustedImageSource[]): boolean {
|
||||
function isTrustedMediaSource(parsedUrl: URL, sources: TrustedMediaSource[]): boolean {
|
||||
const hostname = parsedUrl.hostname.toLowerCase();
|
||||
return sources.some((source) => {
|
||||
const hostMatches = source.includeSubdomains
|
||||
|
|
@ -91,7 +91,11 @@ function isVideoUrl(parsedUrl: URL, settings: AutoPasteLinkSettings): boolean {
|
|||
}
|
||||
|
||||
const extension = parsedUrl.pathname.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
|
||||
return extension ? settings.videoExtensions.includes(extension) : false;
|
||||
if (extension && settings.videoExtensions.includes(extension)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isTrustedMediaSource(parsedUrl, settings.trustedVideoSources);
|
||||
}
|
||||
|
||||
function parseHttpUrl(value: string): URL | null {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,23 @@
|
|||
interface SettingText {
|
||||
language: "zh" | "en";
|
||||
titleCompletionSectionName: string;
|
||||
pasteBehaviorSectionName: string;
|
||||
mediaDetectionSectionName: string;
|
||||
fetchSupportedSiteTitleName: string;
|
||||
fetchSupportedSiteTitleDesc: string;
|
||||
fetchGenericSiteTitleName: string;
|
||||
fetchGenericSiteTitleDesc: string;
|
||||
titleFetchTimeoutName: string;
|
||||
titleFetchTimeoutDesc: string;
|
||||
fabSlowHint: string;
|
||||
supportedSitesName: string;
|
||||
supportedSitesDesc: string;
|
||||
siteSupportSubsectionName: string;
|
||||
githubTitleFormatName: string;
|
||||
githubTitleFormatDesc: string;
|
||||
githubTitleFormatRepository: string;
|
||||
githubTitleFormatOwnerRepository: string;
|
||||
githubTitleFormatGitHubOwnerRepository: string;
|
||||
useSelectionAsLinkTextName: string;
|
||||
useSelectionAsLinkTextDesc: string;
|
||||
processYamlFrontmatterName: string;
|
||||
|
|
@ -38,19 +47,30 @@ interface SettingText {
|
|||
videoSubsectionName: string;
|
||||
videoExtensionsName: string;
|
||||
videoExtensionsDesc: string;
|
||||
trustedVideoSourcesName: string;
|
||||
trustedVideoSourcesDesc: string;
|
||||
}
|
||||
|
||||
const ZH: SettingText = {
|
||||
language: "zh",
|
||||
titleCompletionSectionName: "标题补全",
|
||||
pasteBehaviorSectionName: "粘贴行为",
|
||||
mediaDetectionSectionName: "媒体识别",
|
||||
fetchSupportedSiteTitleName: "自动获取支持站点标题",
|
||||
fetchSupportedSiteTitleDesc: "仅对支持的网站请求标题;其它网站不会联网。",
|
||||
fetchGenericSiteTitleName: "默认获取其它网站标题",
|
||||
fetchGenericSiteTitleDesc: "开启后,未知网站也会尝试获取标题;只有标题足够干净时才会填入。",
|
||||
titleFetchTimeoutName: "标题请求超时",
|
||||
titleFetchTimeoutDesc: "单位毫秒,默认 3000。超时后保留空标题链接。",
|
||||
fabSlowHint: "提示:Fab 访问速度通常较慢,低于 3000ms 可能经常取不到标题。",
|
||||
supportedSitesName: "当前支持站点",
|
||||
supportedSitesDesc: "bilibili、YouTube、Fab、GitHub、Stack Overflow、Stack Exchange、Reddit、Wikipedia、Steam、MDN、npm、知乎、掘金、CSDN、微信公众号、豆瓣、博客园。",
|
||||
supportedSitesDesc: "按类型分组显示当前可自动补全标题的网站。",
|
||||
siteSupportSubsectionName: "站点支持",
|
||||
githubTitleFormatName: "GitHub 仓库标题格式",
|
||||
githubTitleFormatDesc: "仅影响 GitHub 仓库根链接,例如 github.com/owner/repo。",
|
||||
githubTitleFormatRepository: "仅项目名",
|
||||
githubTitleFormatOwnerRepository: "作者/项目名",
|
||||
githubTitleFormatGitHubOwnerRepository: "GitHub - 作者/项目名",
|
||||
useSelectionAsLinkTextName: "选中文本作为链接标题",
|
||||
useSelectionAsLinkTextDesc: "开启后,选中文字再粘贴普通 URL 会生成 [选中文字](URL)。",
|
||||
processYamlFrontmatterName: "处理 YAML frontmatter",
|
||||
|
|
@ -80,19 +100,30 @@ const ZH: SettingText = {
|
|||
videoSubsectionName: "视频",
|
||||
videoExtensionsName: "视频扩展名",
|
||||
videoExtensionsDesc: "每行或逗号分隔一个扩展名,默认 mp4,不需要写点号。",
|
||||
trustedVideoSourcesName: "自定义可信视频来源",
|
||||
trustedVideoSourcesDesc: "适合添加明确只承载视频的站点或路径。普通 CDN 不建议添加。",
|
||||
};
|
||||
|
||||
const EN: SettingText = {
|
||||
language: "en",
|
||||
titleCompletionSectionName: "Title completion",
|
||||
pasteBehaviorSectionName: "Paste behavior",
|
||||
mediaDetectionSectionName: "Media detection",
|
||||
fetchSupportedSiteTitleName: "Fetch titles for supported sites",
|
||||
fetchSupportedSiteTitleDesc: "Only supported sites are requested. Other sites never trigger title requests.",
|
||||
fetchGenericSiteTitleName: "Fetch titles for other sites by default",
|
||||
fetchGenericSiteTitleDesc: "When enabled, unknown sites are requested too. Titles are filled only when they look clean.",
|
||||
titleFetchTimeoutName: "Title request timeout",
|
||||
titleFetchTimeoutDesc: "In milliseconds. Default: 3000. Empty link titles are kept after timeout.",
|
||||
fabSlowHint: "Note: Fab is usually slow to access. Values below 3000ms may often fail to fetch its title.",
|
||||
supportedSitesName: "Supported sites",
|
||||
supportedSitesDesc: "bilibili, YouTube, Fab, GitHub, Stack Overflow, Stack Exchange, Reddit, Wikipedia, Steam, MDN, npm, Zhihu, Juejin, CSDN, WeChat Official Accounts, Douban, CNBlogs.",
|
||||
supportedSitesDesc: "Supported title sites are grouped by content type.",
|
||||
siteSupportSubsectionName: "Site support",
|
||||
githubTitleFormatName: "GitHub repository title format",
|
||||
githubTitleFormatDesc: "Only affects GitHub repository root links, such as github.com/owner/repo.",
|
||||
githubTitleFormatRepository: "Repository only",
|
||||
githubTitleFormatOwnerRepository: "Owner/repository",
|
||||
githubTitleFormatGitHubOwnerRepository: "GitHub - owner/repository",
|
||||
useSelectionAsLinkTextName: "Use selection as link title",
|
||||
useSelectionAsLinkTextDesc: "When enabled, selecting text before pasting a normal URL creates [selected text](URL).",
|
||||
processYamlFrontmatterName: "Process YAML frontmatter",
|
||||
|
|
@ -122,6 +153,8 @@ const EN: SettingText = {
|
|||
videoSubsectionName: "Videos",
|
||||
videoExtensionsName: "Video extensions",
|
||||
videoExtensionsDesc: "Enter one extension per line or separate them with commas. Default: mp4. Do not include the dot.",
|
||||
trustedVideoSourcesName: "Custom trusted video sources",
|
||||
trustedVideoSourcesDesc: "Use this for hosts or paths that clearly serve videos. Avoid generic CDN domains.",
|
||||
};
|
||||
|
||||
export function getSettingText(): SettingText {
|
||||
|
|
|
|||
|
|
@ -1,23 +1,30 @@
|
|||
import type { GitHubTitleFormat } from "../core/titleProviders/types.ts";
|
||||
|
||||
export interface AutoPasteLinkSettings {
|
||||
processYamlFrontmatter: boolean;
|
||||
useSelectionAsLinkText: boolean;
|
||||
addNewlineAfterImage: boolean;
|
||||
fetchSupportedSiteTitle: boolean;
|
||||
fetchGenericSiteTitle: boolean;
|
||||
titleFetchTimeoutMs: number;
|
||||
embedImageLinks: boolean;
|
||||
embedVideoLinks: boolean;
|
||||
imageExtensions: string[];
|
||||
trustedImageSources: TrustedImageSource[];
|
||||
trustedVideoSources: TrustedMediaSource[];
|
||||
imageUrlPatterns: string[];
|
||||
videoExtensions: string[];
|
||||
githubTitleFormat: GitHubTitleFormat;
|
||||
}
|
||||
|
||||
export interface TrustedImageSource {
|
||||
export interface TrustedMediaSource {
|
||||
host: string;
|
||||
pathPrefix: string;
|
||||
includeSubdomains: boolean;
|
||||
}
|
||||
|
||||
export type TrustedImageSource = TrustedMediaSource;
|
||||
|
||||
export const BUILTIN_TRUSTED_IMAGE_SOURCES: TrustedImageSource[] = [
|
||||
{
|
||||
host: "images.unsplash.com",
|
||||
|
|
@ -71,6 +78,7 @@ export const DEFAULT_SETTINGS: AutoPasteLinkSettings = {
|
|||
useSelectionAsLinkText: true,
|
||||
addNewlineAfterImage: true,
|
||||
fetchSupportedSiteTitle: true,
|
||||
fetchGenericSiteTitle: true,
|
||||
titleFetchTimeoutMs: 3000,
|
||||
embedImageLinks: true,
|
||||
embedVideoLinks: true,
|
||||
|
|
@ -85,12 +93,14 @@ export const DEFAULT_SETTINGS: AutoPasteLinkSettings = {
|
|||
"avif"
|
||||
],
|
||||
trustedImageSources: [],
|
||||
trustedVideoSources: [],
|
||||
imageUrlPatterns: [
|
||||
"[?&](?:format|fm|type|mime)=([^&#]*)(?:jpg|jpeg|png|gif|webp|avif|svg)"
|
||||
],
|
||||
videoExtensions: [
|
||||
"mp4"
|
||||
]
|
||||
],
|
||||
githubTitleFormat: "owner-repository",
|
||||
};
|
||||
|
||||
export function normalizeSettings(value: Partial<AutoPasteLinkSettings>): AutoPasteLinkSettings {
|
||||
|
|
@ -99,6 +109,7 @@ export function normalizeSettings(value: Partial<AutoPasteLinkSettings>): AutoPa
|
|||
useSelectionAsLinkText: value.useSelectionAsLinkText ?? DEFAULT_SETTINGS.useSelectionAsLinkText,
|
||||
addNewlineAfterImage: value.addNewlineAfterImage ?? DEFAULT_SETTINGS.addNewlineAfterImage,
|
||||
fetchSupportedSiteTitle: value.fetchSupportedSiteTitle ?? DEFAULT_SETTINGS.fetchSupportedSiteTitle,
|
||||
fetchGenericSiteTitle: value.fetchGenericSiteTitle ?? DEFAULT_SETTINGS.fetchGenericSiteTitle,
|
||||
titleFetchTimeoutMs: normalizeTitleFetchTimeoutMs(
|
||||
value.titleFetchTimeoutMs ?? DEFAULT_SETTINGS.titleFetchTimeoutMs
|
||||
),
|
||||
|
|
@ -108,8 +119,12 @@ export function normalizeSettings(value: Partial<AutoPasteLinkSettings>): AutoPa
|
|||
trustedImageSources: normalizeTrustedImageSources(
|
||||
value.trustedImageSources ?? DEFAULT_SETTINGS.trustedImageSources
|
||||
),
|
||||
trustedVideoSources: normalizeTrustedVideoSources(
|
||||
value.trustedVideoSources ?? DEFAULT_SETTINGS.trustedVideoSources
|
||||
),
|
||||
imageUrlPatterns: normalizePatternList(value.imageUrlPatterns ?? DEFAULT_SETTINGS.imageUrlPatterns),
|
||||
videoExtensions: normalizeVideoExtensions(value.videoExtensions ?? DEFAULT_SETTINGS.videoExtensions),
|
||||
githubTitleFormat: normalizeGitHubTitleFormat(value.githubTitleFormat),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -137,16 +152,30 @@ export function normalizePatternList(value: string[] | string): string[] {
|
|||
}
|
||||
|
||||
export function normalizeTrustedImageSources(value: TrustedImageSource[]): TrustedImageSource[] {
|
||||
return normalizeTrustedMediaSources(value);
|
||||
}
|
||||
|
||||
export function normalizeTrustedVideoSources(value: TrustedMediaSource[]): TrustedMediaSource[] {
|
||||
return normalizeTrustedMediaSources(value);
|
||||
}
|
||||
|
||||
export function normalizeGitHubTitleFormat(value: unknown): GitHubTitleFormat {
|
||||
return value === "repository" || value === "github-owner-repository" || value === "owner-repository"
|
||||
? value
|
||||
: DEFAULT_SETTINGS.githubTitleFormat;
|
||||
}
|
||||
|
||||
function normalizeTrustedMediaSources(value: TrustedMediaSource[]): TrustedMediaSource[] {
|
||||
const seen = new Set<string>();
|
||||
const result: TrustedImageSource[] = [];
|
||||
const result: TrustedMediaSource[] = [];
|
||||
|
||||
for (const source of value) {
|
||||
const host = normalizeTrustedImageSourceHost(source.host);
|
||||
const host = normalizeTrustedMediaSourceHost(source.host);
|
||||
if (!host) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pathPrefix = normalizeTrustedImageSourcePathPrefix(source.pathPrefix);
|
||||
const pathPrefix = normalizeTrustedMediaSourcePathPrefix(source.pathPrefix);
|
||||
const includeSubdomains = Boolean(source.includeSubdomains);
|
||||
const key = `${host}\n${pathPrefix}\n${includeSubdomains}`;
|
||||
if (seen.has(key)) {
|
||||
|
|
@ -182,7 +211,7 @@ function normalizeLineList(value: string[] | string): string[] {
|
|||
return result;
|
||||
}
|
||||
|
||||
function normalizeTrustedImageSourceHost(value: string): string | null {
|
||||
function normalizeTrustedMediaSourceHost(value: string): string | null {
|
||||
const rawHost = value.trim().toLowerCase();
|
||||
if (!rawHost) {
|
||||
return null;
|
||||
|
|
@ -198,7 +227,7 @@ function normalizeTrustedImageSourceHost(value: string): string | null {
|
|||
return /^[a-z0-9.-]+$/.test(host) ? host : null;
|
||||
}
|
||||
|
||||
function normalizeTrustedImageSourcePathPrefix(value: string): string {
|
||||
function normalizeTrustedMediaSourcePathPrefix(value: string): string {
|
||||
const pathPrefix = value.trim();
|
||||
if (!pathPrefix || pathPrefix === "/") {
|
||||
return "";
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
import { App, PluginSettingTab, Setting } from "obsidian";
|
||||
import { SUPPORTED_SITE_GROUPS, type SupportedSiteGroup } from "../core/titleResolver";
|
||||
import type AutoPasteLinkPlugin from "../main";
|
||||
import { getSettingText } from "./i18n";
|
||||
import {
|
||||
BUILTIN_TRUSTED_IMAGE_SOURCES,
|
||||
normalizeGitHubTitleFormat,
|
||||
normalizeImageExtensions,
|
||||
normalizePatternList,
|
||||
normalizeTitleFetchTimeoutMs,
|
||||
normalizeTrustedImageSources,
|
||||
normalizeTrustedVideoSources,
|
||||
normalizeVideoExtensions,
|
||||
type TrustedImageSource,
|
||||
type TrustedMediaSource,
|
||||
} from "./pluginSettings";
|
||||
|
||||
export class AutoPasteLinkSettingTab extends PluginSettingTab {
|
||||
|
|
@ -49,9 +52,35 @@ export class AutoPasteLinkSettingTab extends PluginSettingTab {
|
|||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
const siteSupportSection = addSubsection(containerEl, text.siteSupportSubsectionName);
|
||||
new Setting(siteSupportSection)
|
||||
.setName(text.supportedSitesName)
|
||||
.setDesc(text.supportedSitesDesc);
|
||||
.setDesc(createSupportedSitesDescription(SUPPORTED_SITE_GROUPS, text.language, text.supportedSitesDesc));
|
||||
|
||||
new Setting(siteSupportSection)
|
||||
.setName(text.fetchGenericSiteTitleName)
|
||||
.setDesc(text.fetchGenericSiteTitleDesc)
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.fetchGenericSiteTitle).onChange(async (value) => {
|
||||
this.plugin.settings.fetchGenericSiteTitle = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(siteSupportSection)
|
||||
.setName(text.githubTitleFormatName)
|
||||
.setDesc(text.githubTitleFormatDesc)
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
.addOption("repository", text.githubTitleFormatRepository)
|
||||
.addOption("owner-repository", text.githubTitleFormatOwnerRepository)
|
||||
.addOption("github-owner-repository", text.githubTitleFormatGitHubOwnerRepository)
|
||||
.setValue(this.plugin.settings.githubTitleFormat)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.githubTitleFormat = normalizeGitHubTitleFormat(value);
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
addSection(containerEl, text.pasteBehaviorSectionName);
|
||||
|
||||
|
|
@ -114,7 +143,7 @@ export class AutoPasteLinkSettingTab extends PluginSettingTab {
|
|||
|
||||
new Setting(imageSection)
|
||||
.setName(text.builtinTrustedImageSourcesName)
|
||||
.setDesc(createTrustedImageSourcesDescription(BUILTIN_TRUSTED_IMAGE_SOURCES));
|
||||
.setDesc(createTrustedMediaSourcesDescription(BUILTIN_TRUSTED_IMAGE_SOURCES));
|
||||
|
||||
new Setting(imageSection)
|
||||
.setName(text.trustedImageSourcesName)
|
||||
|
|
@ -125,7 +154,7 @@ export class AutoPasteLinkSettingTab extends PluginSettingTab {
|
|||
.onClick(() => {
|
||||
this.plugin.settings.trustedImageSources = [
|
||||
...this.plugin.settings.trustedImageSources,
|
||||
createEmptyTrustedImageSource(),
|
||||
createEmptyTrustedMediaSource(),
|
||||
];
|
||||
this.renderSettings();
|
||||
})
|
||||
|
|
@ -216,17 +245,89 @@ export class AutoPasteLinkSettingTab extends PluginSettingTab {
|
|||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(videoSection)
|
||||
.setName(text.trustedVideoSourcesName)
|
||||
.setDesc(text.trustedVideoSourcesDesc)
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText(text.addTrustedImageSourceButtonText)
|
||||
.onClick(() => {
|
||||
this.plugin.settings.trustedVideoSources = [
|
||||
...this.plugin.settings.trustedVideoSources,
|
||||
createEmptyTrustedMediaSource(),
|
||||
];
|
||||
this.renderSettings();
|
||||
})
|
||||
);
|
||||
|
||||
this.plugin.settings.trustedVideoSources.forEach((source, index) => {
|
||||
new Setting(videoSection)
|
||||
.setName(`${text.trustedImageSourceRowName} ${index + 1}`)
|
||||
.setDesc(text.trustedImageSourceRowDesc)
|
||||
.addText((input) =>
|
||||
input
|
||||
.setPlaceholder(text.trustedImageSourceHostPlaceholder)
|
||||
.setValue(source.host)
|
||||
.onChange(async (value) => {
|
||||
await this.updateTrustedVideoSource(index, {
|
||||
host: value,
|
||||
});
|
||||
})
|
||||
)
|
||||
.addText((input) =>
|
||||
input
|
||||
.setPlaceholder(text.trustedImageSourcePathPrefixPlaceholder)
|
||||
.setValue(source.pathPrefix)
|
||||
.onChange(async (value) => {
|
||||
await this.updateTrustedVideoSource(index, {
|
||||
pathPrefix: value,
|
||||
});
|
||||
})
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setTooltip(text.trustedImageSourceIncludeSubdomainsText)
|
||||
.setValue(source.includeSubdomains)
|
||||
.onChange(async (value) => {
|
||||
await this.updateTrustedVideoSource(index, {
|
||||
includeSubdomains: value,
|
||||
});
|
||||
})
|
||||
)
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText(text.deleteTrustedImageSourceButtonText)
|
||||
.onClick(async () => {
|
||||
this.plugin.settings.trustedVideoSources = this.plugin.settings.trustedVideoSources.filter(
|
||||
(_, sourceIndex) => sourceIndex !== index
|
||||
);
|
||||
await this.plugin.saveSettings();
|
||||
this.renderSettings();
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async updateTrustedImageSource(index: number, patch: Partial<TrustedImageSource>): Promise<void> {
|
||||
private async updateTrustedImageSource(index: number, patch: Partial<TrustedMediaSource>): Promise<void> {
|
||||
const sources = [...this.plugin.settings.trustedImageSources];
|
||||
sources[index] = {
|
||||
...(sources[index] ?? createEmptyTrustedImageSource()),
|
||||
...(sources[index] ?? createEmptyTrustedMediaSource()),
|
||||
...patch,
|
||||
};
|
||||
this.plugin.settings.trustedImageSources = normalizeTrustedImageSources(sources);
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
|
||||
private async updateTrustedVideoSource(index: number, patch: Partial<TrustedMediaSource>): Promise<void> {
|
||||
const sources = [...this.plugin.settings.trustedVideoSources];
|
||||
sources[index] = {
|
||||
...(sources[index] ?? createEmptyTrustedMediaSource()),
|
||||
...patch,
|
||||
};
|
||||
this.plugin.settings.trustedVideoSources = normalizeTrustedVideoSources(sources);
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
function addSection(containerEl: HTMLElement, name: string): void {
|
||||
|
|
@ -239,9 +340,6 @@ function addSection(containerEl: HTMLElement, name: string): void {
|
|||
function addSubsection(containerEl: HTMLElement, name: string): HTMLElement {
|
||||
const details = containerEl.createEl("details", {
|
||||
cls: "auto-paste-link-subsection",
|
||||
attr: {
|
||||
open: "",
|
||||
},
|
||||
});
|
||||
details.createEl("summary", {
|
||||
cls: "auto-paste-link-subsection-summary",
|
||||
|
|
@ -265,7 +363,28 @@ function createDescription(description: string, hint: string): DocumentFragment
|
|||
return fragment;
|
||||
}
|
||||
|
||||
function createTrustedImageSourcesDescription(sources: TrustedImageSource[]): DocumentFragment {
|
||||
function createSupportedSitesDescription(
|
||||
groups: readonly SupportedSiteGroup[],
|
||||
language: "zh" | "en",
|
||||
description: string
|
||||
): DocumentFragment {
|
||||
const fragment = activeDocument.createDocumentFragment();
|
||||
fragment.append(description);
|
||||
|
||||
const list = activeDocument.createElement("ul");
|
||||
for (const group of groups) {
|
||||
const item = activeDocument.createElement("li");
|
||||
const groupName = language === "zh" ? group.zhName : group.enName;
|
||||
const separator = language === "zh" ? "、" : ", ";
|
||||
item.textContent = `${groupName}: ${group.providers.map((provider) => provider.displayName).join(separator)}`;
|
||||
list.append(item);
|
||||
}
|
||||
|
||||
fragment.append(list);
|
||||
return fragment;
|
||||
}
|
||||
|
||||
function createTrustedMediaSourcesDescription(sources: TrustedMediaSource[]): DocumentFragment {
|
||||
const fragment = activeDocument.createDocumentFragment();
|
||||
const list = activeDocument.createElement("ul");
|
||||
|
||||
|
|
@ -280,7 +399,7 @@ function createTrustedImageSourcesDescription(sources: TrustedImageSource[]): Do
|
|||
return fragment;
|
||||
}
|
||||
|
||||
function createEmptyTrustedImageSource(): TrustedImageSource {
|
||||
function createEmptyTrustedMediaSource(): TrustedMediaSource {
|
||||
return {
|
||||
host: "",
|
||||
pathPrefix: "",
|
||||
|
|
|
|||
|
|
@ -16,11 +16,16 @@ import { PasteShortcutTracker } from "../src/core/pasteShortcutTracker.ts";
|
|||
import { DEFAULT_SETTINGS, normalizeSettings } from "../src/settings/pluginSettings.ts";
|
||||
import {
|
||||
cleanBilibiliTitle,
|
||||
cleanCsdnTitle,
|
||||
cleanFabTitle,
|
||||
cleanSteamTitle,
|
||||
cleanYouTubeTitle,
|
||||
extractGenericHtmlTitle,
|
||||
extractHtmlTitle,
|
||||
getSupportedTitleProvider,
|
||||
resolveSupportedSiteTitle,
|
||||
SUPPORTED_SITE_GROUPS,
|
||||
SUPPORTED_SITE_NAMES,
|
||||
type TitleRequest,
|
||||
} from "../src/core/titleResolver.ts";
|
||||
|
||||
|
|
@ -150,6 +155,53 @@ test("用户可信图片来源可选择包含子域名", () => {
|
|||
assert.equal(classifyUrlText("https://sub.cdn.example.com/resource", settings).kind, "image-link");
|
||||
});
|
||||
|
||||
test("用户可信视频来源按 host 和路径前缀识别", () => {
|
||||
const settings = normalizeSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
trustedVideoSources: [
|
||||
{
|
||||
host: "media.example.com",
|
||||
pathPrefix: "/videos/",
|
||||
includeSubdomains: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(classifyUrlText("https://media.example.com/videos/resource", settings).kind, "video-link");
|
||||
assert.equal(classifyUrlText("https://media.example.com/files/resource", settings).kind, "normal-link");
|
||||
assert.equal(classifyUrlText("https://sub.media.example.com/videos/resource", settings).kind, "normal-link");
|
||||
});
|
||||
|
||||
test("用户可信视频来源可选择包含子域名", () => {
|
||||
const settings = normalizeSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
trustedVideoSources: [
|
||||
{
|
||||
host: "media.example.com",
|
||||
pathPrefix: "",
|
||||
includeSubdomains: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(classifyUrlText("https://sub.media.example.com/resource", settings).kind, "video-link");
|
||||
});
|
||||
|
||||
test("同时命中视频可信来源和图片扩展名时图片优先", () => {
|
||||
const settings = normalizeSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
trustedVideoSources: [
|
||||
{
|
||||
host: "media.example.com",
|
||||
pathPrefix: "/videos/",
|
||||
includeSubdomains: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(classifyUrlText("https://media.example.com/videos/cover.jpg", settings).kind, "image-link");
|
||||
});
|
||||
|
||||
test("高级正则规则仍可识别查询参数图片 URL", () => {
|
||||
assert.equal(classifyUrlText("https://example.com/resource?id=1&format=jpg", DEFAULT_SETTINGS).kind, "image-link");
|
||||
});
|
||||
|
|
@ -260,22 +312,56 @@ test("supported title providers match only important sites", () => {
|
|||
assert.equal(getSupportedTitleProvider("https://youtu.be/dQw4w9WgXcQ"), "YouTube");
|
||||
assert.equal(getSupportedTitleProvider("https://www.fab.com/listings/example"), "Fab");
|
||||
assert.equal(getSupportedTitleProvider("https://github.com/cnwenzhihong/Obsidian-auto-paste-link"), "GitHub");
|
||||
assert.equal(getSupportedTitleProvider("https://gitlab.com/gitlab-org/gitlab"), "GitLab");
|
||||
assert.equal(getSupportedTitleProvider("https://gitee.com/oschina/git-osc"), "Gitee");
|
||||
assert.equal(getSupportedTitleProvider("https://stackoverflow.com/questions/1/example"), "Stack Overflow");
|
||||
assert.equal(getSupportedTitleProvider("https://superuser.com/questions/1/example"), "Stack Exchange");
|
||||
assert.equal(getSupportedTitleProvider("https://www.reddit.com/r/ObsidianMD/comments/example"), "Reddit");
|
||||
assert.equal(getSupportedTitleProvider("https://en.wikipedia.org/wiki/Obsidian"), "Wikipedia");
|
||||
assert.equal(getSupportedTitleProvider("https://store.steampowered.com/app/620/Portal_2/"), "Steam");
|
||||
assert.equal(getSupportedTitleProvider("https://www.themoviedb.org/movie/27205-inception"), "TMDb");
|
||||
assert.equal(getSupportedTitleProvider("https://letterboxd.com/film/inception/"), "Letterboxd");
|
||||
assert.equal(getSupportedTitleProvider("https://www.rottentomatoes.com/m/inception"), "Rotten Tomatoes");
|
||||
assert.equal(getSupportedTitleProvider("https://myanimelist.net/anime/20/Naruto"), "MyAnimeList");
|
||||
assert.equal(getSupportedTitleProvider("https://bangumi.tv/subject/975"), "Bangumi");
|
||||
assert.equal(getSupportedTitleProvider("https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT"), "Spotify");
|
||||
assert.equal(getSupportedTitleProvider("https://www.dailymotion.com/video/x84sh87"), "Dailymotion");
|
||||
assert.equal(getSupportedTitleProvider("https://developer.mozilla.org/en-US/docs/Web/API/URL"), "MDN");
|
||||
assert.equal(getSupportedTitleProvider("https://www.npmjs.com/package/obsidian"), "npm");
|
||||
assert.equal(getSupportedTitleProvider("https://www.zhihu.com/question/123"), "Zhihu");
|
||||
assert.equal(getSupportedTitleProvider("https://pypi.org/project/requests/"), "PyPI");
|
||||
assert.equal(getSupportedTitleProvider("https://crates.io/crates/serde"), "crates.io");
|
||||
assert.equal(getSupportedTitleProvider("https://pkg.go.dev/net/http"), "Go Packages");
|
||||
assert.equal(getSupportedTitleProvider("https://developer.apple.com/documentation/foundation/url"), "Apple Developer");
|
||||
assert.equal(getSupportedTitleProvider("https://unity.com/products/unity-engine"), "Unity");
|
||||
assert.equal(getSupportedTitleProvider("https://nextjs.org/docs"), "Next.js");
|
||||
assert.equal(getSupportedTitleProvider("https://vuejs.org/guide/introduction.html"), "Vue.js");
|
||||
assert.equal(getSupportedTitleProvider("https://nodejs.org/en/learn/getting-started/introduction-to-nodejs"), "Node.js");
|
||||
assert.equal(getSupportedTitleProvider("https://www.zhihu.com/question/123"), null);
|
||||
assert.equal(getSupportedTitleProvider("https://juejin.cn/post/123"), "Juejin");
|
||||
assert.equal(getSupportedTitleProvider("https://blog.csdn.net/example/article/details/1"), "CSDN");
|
||||
assert.equal(getSupportedTitleProvider("https://cloud.tencent.com/developer/article/2400200"), "Tencent Cloud Developer");
|
||||
assert.equal(getSupportedTitleProvider("https://developer.aliyun.com/article/1488487"), "Alibaba Cloud Developer");
|
||||
assert.equal(getSupportedTitleProvider("https://mp.weixin.qq.com/s/example"), "WeChat Official Accounts");
|
||||
assert.equal(getSupportedTitleProvider("https://movie.douban.com/subject/1292052/"), "Douban");
|
||||
assert.equal(getSupportedTitleProvider("https://www.cnblogs.com/example/p/1.html"), "CNBlogs");
|
||||
assert.equal(getSupportedTitleProvider("https://segmentfault.com/a/1190000040000000"), "SegmentFault");
|
||||
assert.equal(getSupportedTitleProvider("https://www.jianshu.com/p/1"), "Jianshu");
|
||||
assert.equal(getSupportedTitleProvider("https://example.com"), null);
|
||||
});
|
||||
|
||||
test("supported title site names are generated from groups", () => {
|
||||
const groupedNames = SUPPORTED_SITE_GROUPS.flatMap((group) =>
|
||||
group.providers.map((provider) => provider.displayName)
|
||||
);
|
||||
|
||||
assert.deepEqual(SUPPORTED_SITE_NAMES, groupedNames);
|
||||
assert.equal(new Set(SUPPORTED_SITE_NAMES).size, SUPPORTED_SITE_NAMES.length);
|
||||
assert.deepEqual(
|
||||
SUPPORTED_SITE_GROUPS.map((group) => group.id),
|
||||
["video-creation", "entertainment", "code-development", "community-knowledge", "chinese-content"]
|
||||
);
|
||||
});
|
||||
|
||||
test("HTML title extraction prefers Open Graph over Twitter and title tags", () => {
|
||||
assert.equal(
|
||||
extractHtmlTitle(`
|
||||
|
|
@ -287,10 +373,44 @@ test("HTML title extraction prefers Open Graph over Twitter and title tags", ()
|
|||
);
|
||||
});
|
||||
|
||||
test("generic title extraction accepts clean social title", () => {
|
||||
assert.equal(
|
||||
extractGenericHtmlTitle(
|
||||
'<title>Ignored</title><meta property="og:title" content="Clean title">',
|
||||
new URL("https://example.com/article")
|
||||
),
|
||||
"Clean title"
|
||||
);
|
||||
});
|
||||
|
||||
test("generic title extraction strips a host-related suffix", () => {
|
||||
assert.equal(
|
||||
extractGenericHtmlTitle("<title>Clean title - example.com</title>", new URL("https://example.com/article")),
|
||||
"Clean title"
|
||||
);
|
||||
});
|
||||
|
||||
test("generic title extraction rejects noisy multi-part titles", () => {
|
||||
assert.equal(
|
||||
extractGenericHtmlTitle("<title>A - B - C | D</title>", new URL("https://example.com/article")),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test("generic title extraction rejects blocked verification titles", () => {
|
||||
assert.equal(
|
||||
extractGenericHtmlTitle("<title>Just a moment...</title>", new URL("https://example.com/article")),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test("site title cleanup removes common suffixes", () => {
|
||||
assert.equal(cleanBilibiliTitle("视频标题_哔哩哔哩_bilibili"), "视频标题");
|
||||
assert.equal(cleanYouTubeTitle("Video title - YouTube"), "Video title");
|
||||
assert.equal(cleanFabTitle("Asset title | Fab"), "Asset title");
|
||||
assert.equal(cleanSteamTitle("Save 30% on DYNASTY WARRIORS: ORIGINS on Steam"), "DYNASTY WARRIORS: ORIGINS");
|
||||
assert.equal(cleanSteamTitle("在 Steam 上购买 真・三国无双 起源 立省 30%"), "真・三国无双 起源");
|
||||
assert.equal(cleanCsdnTitle("UE5 Lyra中的UI层级与资产管理_commonui ue-CSDN博客"), "UE5 Lyra中的UI层级与资产管理");
|
||||
});
|
||||
|
||||
test("bilibili title resolver uses API title when BV id is available", async () => {
|
||||
|
|
@ -350,22 +470,294 @@ test("fab title resolver extracts page title", async () => {
|
|||
);
|
||||
});
|
||||
|
||||
test("steam title resolver uses appdetails name when app id is available", async () => {
|
||||
const request: TitleRequest = async (input) => {
|
||||
assert.match(input.url, /^https:\/\/store\.steampowered\.com\/api\/appdetails\?/);
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
text: JSON.stringify({
|
||||
2384580: {
|
||||
success: true,
|
||||
data: {
|
||||
name: "真・三国无双 起源",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
await resolveSupportedSiteTitle("https://store.steampowered.com/app/2384580/_/", {
|
||||
request,
|
||||
timeoutMs: 500,
|
||||
}),
|
||||
"真・三国无双 起源"
|
||||
);
|
||||
});
|
||||
|
||||
test("entertainment oEmbed providers use API titles", async () => {
|
||||
const request: TitleRequest = async (input) => {
|
||||
if (input.url.startsWith("https://open.spotify.com/oembed?")) {
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
text: JSON.stringify({ title: "Never Gonna Give You Up" }),
|
||||
};
|
||||
}
|
||||
|
||||
assert.match(input.url, /^https:\/\/www\.dailymotion\.com\/services\/oembed\?/);
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
text: JSON.stringify({ title: "Dailymotion demo video" }),
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
await resolveSupportedSiteTitle("https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT", {
|
||||
request,
|
||||
timeoutMs: 500,
|
||||
}),
|
||||
"Never Gonna Give You Up"
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSupportedSiteTitle("https://www.dailymotion.com/video/x84sh87", {
|
||||
request,
|
||||
timeoutMs: 500,
|
||||
}),
|
||||
"Dailymotion demo video"
|
||||
);
|
||||
});
|
||||
|
||||
test("github repository title format can be configured", async () => {
|
||||
const request: TitleRequest = async (input) => ({
|
||||
status: 200,
|
||||
headers: {},
|
||||
text: `<title>${input.url}</title>`,
|
||||
});
|
||||
|
||||
const url = "https://github.com/cnwenzhihong/Obsidian-auto-paste-link";
|
||||
assert.equal(
|
||||
await resolveSupportedSiteTitle(url, {
|
||||
request,
|
||||
timeoutMs: 500,
|
||||
githubTitleFormat: "repository",
|
||||
}),
|
||||
"Obsidian-auto-paste-link"
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSupportedSiteTitle(url, {
|
||||
request,
|
||||
timeoutMs: 500,
|
||||
githubTitleFormat: "owner-repository",
|
||||
}),
|
||||
"cnwenzhihong/Obsidian-auto-paste-link"
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSupportedSiteTitle(url, {
|
||||
request,
|
||||
timeoutMs: 500,
|
||||
githubTitleFormat: "github-owner-repository",
|
||||
}),
|
||||
"GitHub - cnwenzhihong/Obsidian-auto-paste-link"
|
||||
);
|
||||
});
|
||||
|
||||
test("code hosting providers can format repository paths", async () => {
|
||||
const request: TitleRequest = async (input) => ({
|
||||
status: 200,
|
||||
headers: {},
|
||||
text: `<title>${input.url}</title>`,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
await resolveSupportedSiteTitle("https://gitlab.com/gitlab-org/gitlab", {
|
||||
request,
|
||||
timeoutMs: 500,
|
||||
}),
|
||||
"gitlab-org/gitlab"
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSupportedSiteTitle("https://gitee.com/oschina/git-osc", {
|
||||
request,
|
||||
timeoutMs: 500,
|
||||
}),
|
||||
"oschina/git-osc"
|
||||
);
|
||||
});
|
||||
|
||||
test("package registry providers use stable package names", async () => {
|
||||
const request: TitleRequest = async (input) => {
|
||||
if (input.url === "https://registry.npmjs.org/typescript") {
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
text: JSON.stringify({ name: "typescript" }),
|
||||
};
|
||||
}
|
||||
|
||||
if (input.url === "https://pypi.org/pypi/requests/json") {
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
text: JSON.stringify({ info: { name: "requests" } }),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
text: "<title>crates.io: Rust Package Registry</title>",
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
await resolveSupportedSiteTitle("https://www.npmjs.com/package/typescript", {
|
||||
request,
|
||||
timeoutMs: 500,
|
||||
}),
|
||||
"typescript"
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSupportedSiteTitle("https://pypi.org/project/requests/", {
|
||||
request,
|
||||
timeoutMs: 500,
|
||||
}),
|
||||
"requests"
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSupportedSiteTitle("https://crates.io/crates/serde", {
|
||||
request,
|
||||
timeoutMs: 500,
|
||||
}),
|
||||
"serde"
|
||||
);
|
||||
});
|
||||
|
||||
test("blocked verification titles are ignored", async () => {
|
||||
const request: TitleRequest = async () => ({
|
||||
status: 200,
|
||||
headers: {},
|
||||
text: "<title>Reddit - Please wait for verification</title>",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
await resolveSupportedSiteTitle("https://www.reddit.com/r/ObsidianMD/comments/example", {
|
||||
request,
|
||||
timeoutMs: 500,
|
||||
}),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test("wikipedia falls back to URL path title when request fails", async () => {
|
||||
const request: TitleRequest = async () => ({
|
||||
status: 503,
|
||||
headers: {},
|
||||
text: "",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
await resolveSupportedSiteTitle("https://en.wikipedia.org/wiki/Obsidian_(software)", {
|
||||
request,
|
||||
timeoutMs: 500,
|
||||
}),
|
||||
"Obsidian (software)"
|
||||
);
|
||||
});
|
||||
|
||||
test("generic resolver fetches clean titles for unsupported sites when enabled", async () => {
|
||||
const request: TitleRequest = async (input) => {
|
||||
assert.equal(input.url, "https://example.com/article");
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
text: '<meta property="og:title" content="Clean title">',
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
await resolveSupportedSiteTitle("https://example.com/article", {
|
||||
request,
|
||||
timeoutMs: 500,
|
||||
fetchGenericSiteTitle: true,
|
||||
}),
|
||||
"Clean title"
|
||||
);
|
||||
});
|
||||
|
||||
test("generic resolver does not request unsupported sites when disabled", async () => {
|
||||
let called = false;
|
||||
const request: TitleRequest = async () => {
|
||||
called = true;
|
||||
return { status: 200, headers: {}, text: "" };
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
await resolveSupportedSiteTitle("https://example.com/article", {
|
||||
request,
|
||||
timeoutMs: 500,
|
||||
fetchGenericSiteTitle: false,
|
||||
}),
|
||||
null
|
||||
);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test("supported providers take priority over generic title fetching", async () => {
|
||||
const request: TitleRequest = async (input) => {
|
||||
assert.match(input.url, /^https:\/\/store\.steampowered\.com\/api\/appdetails\?/);
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
text: JSON.stringify({
|
||||
620: {
|
||||
success: true,
|
||||
data: {
|
||||
name: "Portal 2",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
await resolveSupportedSiteTitle("https://store.steampowered.com/app/620/Portal_2/", {
|
||||
request,
|
||||
timeoutMs: 500,
|
||||
fetchGenericSiteTitle: true,
|
||||
}),
|
||||
"Portal 2"
|
||||
);
|
||||
});
|
||||
|
||||
test("common HTML title providers extract and clean titles", async () => {
|
||||
const cases: Array<[string, string, string]> = [
|
||||
["https://github.com/cnwenzhihong/Obsidian-auto-paste-link", "Auto Paste Link · GitHub", "Auto Paste Link"],
|
||||
["https://stackoverflow.com/questions/1/example", "How to paste URLs - Stack Overflow", "How to paste URLs"],
|
||||
["https://superuser.com/questions/1/example", "Keyboard shortcut - Super User", "Keyboard shortcut"],
|
||||
["https://www.reddit.com/r/ObsidianMD/comments/example", "Plugin discussion - Reddit", "Plugin discussion"],
|
||||
["https://en.wikipedia.org/wiki/Obsidian", "Obsidian - Wikipedia", "Obsidian"],
|
||||
["https://store.steampowered.com/app/620/Portal_2/", "Portal 2 on Steam", "Portal 2"],
|
||||
["https://www.themoviedb.org/movie/27205-inception", "Inception — The Movie Database (TMDB)", "Inception"],
|
||||
["https://letterboxd.com/film/inception/", "\u200eInception (2010) directed by Christopher Nolan • Reviews, film + cast • Letterboxd", "Inception (2010)"],
|
||||
["https://www.rottentomatoes.com/m/inception", "Inception | Rotten Tomatoes", "Inception"],
|
||||
["https://myanimelist.net/anime/20/Naruto", "Naruto - MyAnimeList.net", "Naruto"],
|
||||
["https://bangumi.tv/subject/975", "ONE PIECE | Bangumi 番组计划", "ONE PIECE"],
|
||||
["https://developer.mozilla.org/en-US/docs/Web/API/URL", "URL | MDN", "URL"],
|
||||
["https://www.npmjs.com/package/obsidian", "obsidian | npm", "obsidian"],
|
||||
["https://www.zhihu.com/question/123", "如何使用 Obsidian? - 知乎", "如何使用 Obsidian?"],
|
||||
["https://pkg.go.dev/net/http", "http package - net/http - Go Packages", "http package - net/http"],
|
||||
["https://developer.apple.com/documentation/foundation/url", "URL | Apple Developer Documentation", "URL"],
|
||||
["https://unity.com/products/unity-engine", "Unity Engine: 2D & 3D Development Platform | Unity", "Unity Engine: 2D & 3D Development Platform"],
|
||||
["https://nextjs.org/docs", "Next.js Docs | Next.js", "Next.js Docs"],
|
||||
["https://vuejs.org/guide/introduction.html", "<meta property=\"og:title\" content=\"Vue.js\"><title>Introduction | Vue.js</title>", "Introduction"],
|
||||
["https://nodejs.org/en/learn/getting-started/introduction-to-nodejs", "Introduction to Node.js | Node.js Learn", "Introduction to Node.js"],
|
||||
["https://juejin.cn/post/123", "前端工程实践 - 掘金", "前端工程实践"],
|
||||
["https://blog.csdn.net/example/article/details/1", "TypeScript 实践-CSDN博客", "TypeScript 实践"],
|
||||
["https://blog.csdn.net/example/article/details/1", "UE5 Lyra中的UI层级与资产管理_commonui ue-CSDN博客", "UE5 Lyra中的UI层级与资产管理"],
|
||||
["https://cloud.tencent.com/developer/article/2400200", "Docker部署Alist全平台网盘神器-腾讯云开发者社区-腾讯云", "Docker部署Alist全平台网盘神器"],
|
||||
["https://developer.aliyun.com/article/1488487", "点击事件中的this|click事件与change事件|v-model-阿里云开发者社区", "点击事件中的this|click事件与change事件|v-model"],
|
||||
["https://mp.weixin.qq.com/s/example", "公众号文章标题 - 微信公众平台", "公众号文章标题"],
|
||||
["https://movie.douban.com/subject/1292052/", "肖申克的救赎 (豆瓣)", "肖申克的救赎"],
|
||||
["https://www.cnblogs.com/example/p/1.html", "博客文章 - 博客园", "博客文章"],
|
||||
["https://segmentfault.com/a/1190000040000000", "文章标题 - SegmentFault 思否", "文章标题"],
|
||||
];
|
||||
|
||||
for (const [url, rawTitle, expectedTitle] of cases) {
|
||||
|
|
@ -374,7 +766,7 @@ test("common HTML title providers extract and clean titles", async () => {
|
|||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
text: `<title>${rawTitle}</title>`,
|
||||
text: rawTitle.includes("<") ? rawTitle : `<title>${rawTitle}</title>`,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,5 +2,6 @@
|
|||
"1.0.0": "1.5.0",
|
||||
"1.0.1": "1.5.0",
|
||||
"1.0.2": "1.5.0",
|
||||
"1.0.3": "1.5.0"
|
||||
"1.0.3": "1.5.0",
|
||||
"1.0.4": "1.5.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue