From a5b2a23ea701dc815f44df8bfe96382a1c2419fb Mon Sep 17 00:00:00 2001 From: Weaxs <459312872@qq.com> Date: Sun, 30 Aug 2026 01:21:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=20monorepo=20=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=8F=91=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-query.md | 13 +- docs/api-query.zh-CN.md | 13 +- scripts/collect-score-evidence.mjs | 52 +++-- scripts/extract-plugin-images.mjs | 31 ++- scripts/gen-api-artifacts.mjs | 27 ++- scripts/gen-plugins-real.mjs | 14 +- scripts/lib/catalog-sql.mjs | 6 + scripts/lib/plugin-images.mjs | 15 +- scripts/lib/plugin-images.test.mjs | 7 + scripts/lib/workspaces.mjs | 88 ++++++++ scripts/lib/workspaces.test.mjs | 70 ++++++ scripts/probe-badge.mjs | 15 +- scripts/probe-downloads.mjs | 19 +- scripts/probe-install.mjs | 205 ++++++++++++++++-- scripts/sync-plugins-db.mjs | 25 ++- server/internal/cache/plugins.go | 2 +- server/internal/httpapi/graphql.go | 17 +- .../internal/httpapi/graphql_schema.graphql | 7 +- server/internal/httpapi/graphql_test.go | 28 ++- server/internal/httpapi/market.go | 12 +- server/internal/httpapi/market_test.go | 27 ++- server/internal/httpapi/plugins_test.go | 2 + server/internal/store/migrate.go | 8 +- server/internal/store/migrate_test.go | 2 + server/internal/store/plugins.go | 45 +++- .../[locale]/plugins/[owner]/[repo]/page.tsx | 8 +- src/app/[locale]/search/page.tsx | 2 +- src/app/api/suggest/route.ts | 4 +- src/components/plugins-browser.tsx | 2 +- src/lib/plugins-db.ts | 31 ++- src/lib/types.ts | 6 + 31 files changed, 690 insertions(+), 113 deletions(-) create mode 100644 scripts/lib/catalog-sql.mjs create mode 100644 scripts/lib/workspaces.mjs create mode 100644 scripts/lib/workspaces.test.mjs diff --git a/docs/api-query.md b/docs/api-query.md index d9e8f90..a10ba2d 100644 --- a/docs/api-query.md +++ b/docs/api-query.md @@ -88,8 +88,11 @@ REST uses `snake_case`; GraphQL uses `camelCase`. Unless noted, numeric `0` and | Meaning | REST | GraphQL | Type / nullability | Source and interpretation | | --- | --- | --- | --- | --- | -| Stable ID | `full_name` | `id`, `fullName` | Non-null string | `owner/repo`, the primary key for every plugin; `id === fullName` | -| Display name | `name` | `name` | Non-null string | GitHub repository name | +| Catalog identity | `id` | `id` | Non-null string | `owner/repo` for single-package repositories; the exact package name for workspace packages | +| Row / route key | `full_name` | `fullName` | Non-null string | Equals `id` for single-package repositories; workspace packages use a stable repository-derived internal key | +| Source repository | `repository_full_name` | `repositoryFullName` | Nullable string, may be omitted | Real GitHub `owner/repo` for a workspace package; `null` when it equals `full_name` | +| Package subdirectory | `package_path` | `packagePath` | Nullable string, may be omitted | Safe repository-relative workspace directory | +| Display name | `name` | `name` | Non-null string | GitHub repository name, or the package name for a workspace package | | Owner | `owner` | `owner` | Non-null string | GitHub owner or organization | | Repository page | `repository_url` | `repositoryUrl` | Non-null URL | GitHub repository-page URL | | Legacy repository page | `url` | `url` (deprecated) | Non-null URL | Same repository-page URL; use `repositoryUrl` in new code | @@ -137,7 +140,7 @@ Current cutoffs are S ≥ 85, A ≥ 70, B ≥ 55, and C for all other scored plu | `install.release_tag` | `install.releaseTag` | REST may omit; GraphQL nullable | Corresponding GitHub Release tag | | `install.probed_at` | `install.probedAt` | Nullable `DateTime` | Last successful write of the installation conclusion | -Installation conclusions come from dshfind probes and editorial maintenance; they are not a guarantee that every command works on every operating system or local environment. Display `kind` and `probedAt`, and treat `cmd: null` as “no usable command currently known”—do not invent a command from a Git URL. +Installation conclusions come from dshfind probes and editorial maintenance; they are not a guarantee that every command works on every operating system or local environment. Workspace packages are discovered from root `package.json#workspaces`, then retained only when their own manifest declares `dsh.bundle`. Display `kind` and `probedAt`, and treat `cmd: null` as “no usable command currently known”—do not invent a command from a Git URL. ### 3.3 Localizations, snapshots, and growth @@ -378,6 +381,8 @@ To register dshfind in the desktop app, open the community market's source manag The contract-paginated catalog endpoint advertised by the manifest, conforming to the `catalog-provider-page` schema (`schemaVersion: "1.0.0"`). +For workspace packages, this external contract encodes the package identity as `npm:` because its `id` schema requires an alphanumeric first character. The REST and GraphQL `id` remains the exact package name. + | Parameter | Default / range | Meaning | | --- | --- | --- | | `q` | — | Keyword match over the catalog | @@ -566,6 +571,8 @@ The base `Plugin` fields correspond directly to section 3's REST table, with cam type Plugin { id: ID! fullName: String! + repositoryFullName: String + packagePath: String name: String! owner: String! repositoryUrl: String! diff --git a/docs/api-query.zh-CN.md b/docs/api-query.zh-CN.md index 5311975..d9a3e43 100644 --- a/docs/api-query.zh-CN.md +++ b/docs/api-query.zh-CN.md @@ -88,8 +88,11 @@ REST 返回 snake_case;GraphQL 返回 camelCase。除非特别说明,数值 | 语义 | REST | GraphQL | 类型/可空性 | 数据来源与解释 | | --- | --- | --- | --- | --- | -| 稳定 ID | `full_name` | `id`、`fullName` | 非空字符串 | `owner/repo`,所有插件主键;`id === fullName` | -| 显示名称 | `name` | `name` | 非空字符串 | GitHub 仓库名 | +| 目录身份 | `id` | `id` | 非空字符串 | 单包仓库为 `owner/repo`;workspace 子包为精确包名 | +| 行 / 路由键 | `full_name` | `fullName` | 非空字符串 | 单包仓库等同于 `id`;workspace 子包使用从仓库派生的内部稳定键 | +| 来源仓库 | `repository_full_name` | `repositoryFullName` | 可空字符串,可能省略 | workspace 子包真实的 GitHub `owner/repo`;与 `full_name` 相同时为 `null` | +| 子包目录 | `package_path` | `packagePath` | 可空字符串,可能省略 | 安全的仓库相对 workspace 目录 | +| 显示名称 | `name` | `name` | 非空字符串 | GitHub 仓库名;workspace 子包为包名 | | 所有者 | `owner` | `owner` | 非空字符串 | GitHub owner/org 名称 | | 仓库页 | `repository_url` | `repositoryUrl` | 非空 URL | GitHub repository page URL | | 旧仓库页字段 | `url` | `url`(deprecated) | 非空 URL | 与仓库页相同;新代码应使用 `repositoryUrl` | @@ -137,7 +140,7 @@ REST 返回 snake_case;GraphQL 返回 camelCase。除非特别说明,数值 | `install.release_tag` | `install.releaseTag` | REST 可能省略;GraphQL 可空 | 对应 GitHub Release tag | | `install.probed_at` | `install.probedAt` | 可空 `DateTime` | 安装结论最后一次成功写入的时间 | -安装结论来自 dshfind 探测和运营维护,不保证在所有平台或本地环境可执行。消费者应显示 `kind`、`probedAt`,并把 `cmd: null` 当作“暂无可用安装命令”,而不是尝试根据 Git URL 自行拼命令。 +安装结论来自 dshfind 探测和运营维护,不保证在所有平台或本地环境可执行。workspace 子包从根 `package.json#workspaces` 发现,并且仅在其自身 manifest 声明 `dsh.bundle` 时收录。消费者应显示 `kind`、`probedAt`,并把 `cmd: null` 当作“暂无可用安装命令”,而不是尝试根据 Git URL 自行拼命令。 ### 3.3 翻译、快照与增长 @@ -378,6 +381,8 @@ curl 'https://api.dshfind.com/market/manifest.json' ### 4.6 标准目录分页:`GET /market/v1/plugins` +workspace 子包在这个外部契约中编码为 `npm:<包名>`,因为其 `id` schema 要求首字符为字母或数字;REST 与 GraphQL 的 `id` 仍是精确包名。 + manifest 所声明的契约分页目录端点,符合 `catalog-provider-page` schema(`schemaVersion: "1.0.0"`)。 | 参数 | 默认 / 范围 | 含义 | @@ -568,6 +573,8 @@ query ListPlugins($after: String, $filter: PluginFilter) { type Plugin { id: ID! fullName: String! + repositoryFullName: String + packagePath: String name: String! owner: String! repositoryUrl: String! diff --git a/scripts/collect-score-evidence.mjs b/scripts/collect-score-evidence.mjs index e634e06..4426273 100644 --- a/scripts/collect-score-evidence.mjs +++ b/scripts/collect-score-evidence.mjs @@ -14,6 +14,7 @@ import { execFileSync } from "node:child_process"; import { writeFileSync } from "node:fs"; import { openDb } from "./lib/db.mjs"; +import { pluginSource, repositoryPath } from "./lib/workspaces.mjs"; const API = "https://api.github.com"; const DAY = 86400_000; @@ -76,13 +77,25 @@ if (!outPath) { process.exit(1); } -const targets = repoArgs.length - ? repoArgs +const targetRows = repoArgs.length + ? ( + await client.execute({ + sql: `SELECT full_name, repository_full_name, package_path + FROM plugins + WHERE lower(full_name) IN (${repoArgs.map(() => "?").join(",")})`, + args: repoArgs.map((name) => name.toLowerCase()), + }) + ).rows : ( await client.execute( - `SELECT full_name FROM plugins WHERE is_featured = 1 AND is_present = 1 ORDER BY stars DESC`, + `SELECT full_name, repository_full_name, package_path + FROM plugins WHERE is_featured = 1 AND is_present = 1 ORDER BY stars DESC`, ) - ).rows.map((r) => String(r.full_name)); + ).rows; +const targetByName = new Map(targetRows.map((row) => [String(row.full_name).toLowerCase(), row])); +const targets = repoArgs.length + ? repoArgs.map((name) => targetByName.get(name.toLowerCase()) ?? { full_name: name }) + : targetRows; const ecoStart = Date.parse( String( @@ -93,22 +106,23 @@ const now = Date.now(); const ecoAgeDays = (now - ecoStart) / DAY; const since90 = new Date(now - 90 * DAY).toISOString(); -async function collect(fullName) { - const [owner] = fullName.split("/"); +async function collect(target) { + const { fullName, repositoryFullName, packagePath } = pluginSource(target); + const [owner] = repositoryFullName.split("/"); - const repo = await (await gh(`/repos/${fullName}`)).json(); + const repo = await (await gh(`/repos/${repositoryFullName}`)).json(); // 近 90 天 commit 总数(per_page=1 翻页数)+ 前 100 个的活跃天数 - const cRes = await gh(`/repos/${fullName}/commits?since=${since90}&per_page=1`); + const cRes = await gh(`/repos/${repositoryFullName}/commits?since=${since90}&per_page=1`); const commits90 = cRes.ok ? (lastPage(cRes) ?? (await cRes.json()).length) : 0; - const cList = await gh(`/repos/${fullName}/commits?since=${since90}&per_page=100`); + const cList = await gh(`/repos/${repositoryFullName}/commits?since=${since90}&per_page=100`); const commitDates = cList.ok ? (await cList.json()).map((c) => (c.commit?.author?.date ?? "").slice(0, 10)) : []; const activeDays = new Set(commitDates.filter(Boolean)).size; // issue + PR(GitHub 把 PR 也算 issue);answered ≈ 有评论或已关闭 - const iRes = await gh(`/repos/${fullName}/issues?state=all&per_page=100&sort=created&direction=desc`); + const iRes = await gh(`/repos/${repositoryFullName}/issues?state=all&per_page=100&sort=created&direction=desc`); const issues = iRes.ok ? await iRes.json() : []; const issuesTotal = issues.length; const issuesAnswered = issues.filter((i) => i.comments > 0 || i.state === "closed").length; @@ -117,7 +131,7 @@ async function collect(fullName) { // package.json(HEAD 上没有则 404) let manifest = null; const pj = await fetchWithRetry( - `https://raw.githubusercontent.com/${fullName}/HEAD/package.json`, + `https://raw.githubusercontent.com/${repositoryFullName}/HEAD/${repositoryPath(packagePath, "package.json")}`, ); if (pj.ok) { try { @@ -151,11 +165,15 @@ async function collect(fullName) { } // release/tag - const rRes = await gh(`/repos/${fullName}/tags?per_page=1`); + const rRes = await gh(`/repos/${repositoryFullName}/tags?per_page=1`); const hasTags = rRes.ok && (await rRes.json()).length > 0; // README 截断给 AI 评审 - const readmeRes = await gh(`/repos/${fullName}/readme`, "application/vnd.github.raw+json"); + const readmeRes = packagePath + ? await fetchWithRetry( + `https://raw.githubusercontent.com/${repositoryFullName}/HEAD/${repositoryPath(packagePath, "README.md")}`, + ) + : await gh(`/repos/${repositoryFullName}/readme`, "application/vnd.github.raw+json"); const readme = readmeRes.ok ? (await readmeRes.text()).slice(0, 3000) : ""; // owner 历史 + 名下最佳其他原创仓 @@ -167,7 +185,7 @@ async function collect(fullName) { ? Math.max( 0, ...repos - .filter((r) => r.full_name !== fullName && !r.fork) + .filter((r) => r.full_name !== repositoryFullName && !r.fork) .map((r) => r.stargazers_count ?? 0), ) : 0; @@ -227,10 +245,10 @@ async function collect(fullName) { const out = []; let rateLimited = null; -for (const fullName of targets) { - process.stdout.write(`采集 ${fullName} … `); +for (const target of targets) { + process.stdout.write(`采集 ${target.full_name} … `); try { - out.push(await collect(fullName)); + out.push(await collect(target)); console.log("ok"); } catch (err) { const msg = err?.message ?? String(err); diff --git a/scripts/extract-plugin-images.mjs b/scripts/extract-plugin-images.mjs index e3ee96f..b5d28d3 100644 --- a/scripts/extract-plugin-images.mjs +++ b/scripts/extract-plugin-images.mjs @@ -23,6 +23,7 @@ import { createHash } from "node:crypto"; import { openDb } from "./lib/db.mjs"; +import { pluginSource, repositoryPath } from "./lib/workspaces.mjs"; import { customSocialPreview, @@ -146,9 +147,10 @@ async function get(url, { asText = false } = {}) { // ---------- 单个仓库的处理 ---------- /** 依次尝试常见 README 文件名,返回第一个取到的正文。 */ -async function fetchReadme(fullName) { +async function fetchReadme(fullName, packagePath = null) { for (const name of README_NAMES) { - const got = await get(`https://raw.githubusercontent.com/${fullName}/HEAD/${name}`, { + const path = repositoryPath(packagePath, name); + const got = await get(`https://raw.githubusercontent.com/${fullName}/HEAD/${path}`, { asText: true, }); if (got?.text) return got.text; @@ -161,17 +163,17 @@ async function fetchReadme(fullName) { * README 优先于社交预览图:README 里的通常是作者精心放的效果图, * 而社交预览图更多是 logo 或宣传banner。 */ -async function findImage(fullName) { - const readme = await fetchReadme(fullName); +async function findImage(fullName, repositoryFullName = fullName, packagePath = null) { + const readme = await fetchReadme(repositoryFullName, packagePath); if (readme) { - const picked = pickPluginImage(readme, fullName); + const picked = pickPluginImage(readme, repositoryFullName, "HEAD", packagePath ?? ""); if (picked) { return { kind: "readme", url: picked.url, raw: picked.sourceUrl, alt: picked.alt }; } } // 回退:仓库主手工上传过的社交预览图。GitHub 自动合成的那张不要—— // 那就是仓库名 + 头像 + 描述,我们自己排版能做得更好。 - const page = await get(`https://github.com/${fullName}`, { asText: true }); + const page = await get(`https://github.com/${repositoryFullName}`, { asText: true }); const social = page?.text ? customSocialPreview(page.text) : null; if (social) return { kind: "social", url: social, raw: social, alt: "" }; return null; @@ -260,8 +262,12 @@ if (!probeOnly) { * --only 直接用给定的仓库,不查库——调试时不该为了一个仓库扫全表。 */ async function pendingTargets() { - if (only.length > 0) return only; const cutoff = new Date(Date.now() - staleDays * 86400_000).toISOString(); + const args = [all || only.length > 0 ? 1 : 0, cutoff, probeOnly ? 0 : 1]; + const selected = only.length + ? ` AND lower(COALESCE(p.repository_full_name, p.full_name)) IN (${only.map(() => "?").join(",")})` + : ""; + args.push(...only.map((name) => name.toLowerCase())); const res = await client.execute({ // is_present / is_offtopic 的口径与 gen-plugins-real.mjs 一致—— // 不在站上渲染的仓库不值得抽图,探它们只是白烧时间和 GitHub 的耐心。 @@ -269,7 +275,7 @@ async function pendingTargets() { // status='found' 必须无条件重取(?3):那是 --probe-only 留下的「找到了但没入库」, // 它同样写了 probed_at。只按新鲜度筛的话,先跑一轮探测再跑真正的抽取, // 第二轮会认为全部都还新鲜而一个都不处理——静默地什么也没干。 - sql: `SELECT p.full_name + sql: `SELECT p.full_name, p.repository_full_name, p.package_path FROM plugins p LEFT JOIN plugin_images i ON i.full_name = p.full_name WHERE p.is_present = 1 AND p.is_offtopic = 0 @@ -277,10 +283,11 @@ async function pendingTargets() { OR i.full_name IS NULL OR i.probed_at < ?2 OR (?3 = 1 AND i.status = 'found')) + ${selected} ORDER BY p.stars DESC`, - args: [all ? 1 : 0, cutoff, probeOnly ? 0 : 1], + args, }); - return res.rows.map((r) => String(r.full_name)); + return res.rows.map(pluginSource); } let targets = await pendingTargets(); @@ -301,7 +308,7 @@ const notes = new Map(); const bytes = { thumb: 0, full: 0 }; let done = 0; -await mapPool(targets, CONCURRENCY, async (fullName) => { +await mapPool(targets, CONCURRENCY, async ({ fullName, repositoryFullName, packagePath }) => { const probedAt = new Date().toISOString(); let row = { full_name: fullName, @@ -319,7 +326,7 @@ await mapPool(targets, CONCURRENCY, async (fullName) => { probed_at: probedAt, }; - const found = await findImage(fullName); + const found = await findImage(fullName, repositoryFullName, packagePath); if (found) { row = { ...row, status: "found", source_kind: found.kind, source_url: found.url, source_raw: found.raw, alt: found.alt || null }; diff --git a/scripts/gen-api-artifacts.mjs b/scripts/gen-api-artifacts.mjs index 5ebafd7..5255c35 100644 --- a/scripts/gen-api-artifacts.mjs +++ b/scripts/gen-api-artifacts.mjs @@ -27,13 +27,16 @@ import { fileURLToPath } from "node:url"; import { openDb } from "./lib/db.mjs"; import { catalogSubdirectory } from "./lib/install.mjs"; +import { catalogItemSQL } from "./lib/catalog-sql.mjs"; +import { catalogIdentity } from "./lib/workspaces.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const outDir = resolve(root, "workers/api-edge/assets"); // 与 server/internal/store/plugins.go loadPluginsSQL 逐字对齐(含行序)。 const LOAD_SQL = ` -SELECT full_name, name, owner, url, description, tags, language, +SELECT full_name, repository_full_name, package_path, + name, owner, url, description, tags, language, stars, contributors, pushed_at, archived, category, score, scored_at, score_version, is_featured, is_insider, is_official, is_risky, risk_note, first_seen_at, last_synced_at, install_cmd, install_kind, install_cmd_auto, pkg_name, pkg_version, @@ -42,6 +45,7 @@ SELECT full_name, name, owner, url, description, tags, language, release_tgz_url, release_tag, install_probed_at, is_plugin FROM plugins WHERE is_present = 1 AND is_offtopic = 0 + AND ${catalogItemSQL("plugins")} ORDER BY is_risky ASC, is_featured * featured_boost DESC, stars DESC, full_name`; // 与桌面端适配器审查口径逐字一致(store/plugins.go 的同名 pattern)。 @@ -118,8 +122,15 @@ function buildInstall(r) { /** 行 → 与 store.Plugin json 标签同序的对象。JS 对象保持插入序,序列化即 Go 的字段序。 */ function toPlugin(r) { const score = r.score === null || r.score === undefined ? null : Number(r.score); - return { + const repositoryFullName = orNull(r.repository_full_name); + const plugin = { full_name: r.full_name, + id: catalogIdentity(r), + }; + const packagePath = orNull(r.package_path); + if (repositoryFullName !== null) plugin.repository_full_name = repositoryFullName; + if (packagePath !== null) plugin.package_path = packagePath; + return Object.assign(plugin, { name: r.name, owner: r.owner, url: r.url, @@ -145,7 +156,7 @@ function toPlugin(r) { install: buildInstall(r), first_seen_at: orNull(r.first_seen_at), last_synced_at: orNull(r.last_synced_at), - }; + }); } /** encoding/json 默认做 HTML 转义;< > & 与 U+2028/29 只会出现在字符串值里,全局替换安全。 */ @@ -241,7 +252,7 @@ function goRFC3339(raw) { * 键的插入顺序即 marketItem 的 struct 字段序,不能按代码可读性重排。 */ function buildMarketItem(r) { - const id = r.full_name; + const id = r.repository_full_name == null ? r.id : `npm:${r.id}`; if (bytes(id) > 160 || !marketIDPattern.test(id)) return null; let name = truncateRunes(plainMarketText(r.name ?? ""), 160); @@ -254,6 +265,8 @@ function buildMarketItem(r) { // store.Plugin 的 RepositoryURL 与 URL 都取 url 列,Go 侧的 "" 兜底恒不触发。 const repoURL = r.url ?? ""; const repository = validMarketHTTPSURL(repoURL) ? { url: repoURL } : null; + const packagePath = catalogSubdirectory(orNull(r.package_path)); + if (repository !== null && packagePath !== null) repository.subdirectory = packagePath; // npm 安装信息门控:只看 npm_desktop_installable + 非空版本 + 包名合法。 // 注意这里**不要求**稳定版 semver(buildInstall 的 methods 才要),别顺手对齐。 @@ -270,7 +283,9 @@ function buildMarketItem(r) { // monorepo 子包:随安装证据发 repository.subdirectory(Go 侧 validMarketSubdirectory // 的口径;catalogSubdirectory 与探测入库是同一个函数)。键序 url → subdirectory。 const subdirectory = catalogSubdirectory(orNull(r.npm_repo_directory)); - if (repository !== null && subdirectory !== null) repository.subdirectory = subdirectory; + if (repository !== null && repository.subdirectory === undefined && subdirectory !== null) { + repository.subdirectory = subdirectory; + } } // publisher.url 拼的是**原始** owner,不是 plainMarketText 洗过的那个。 @@ -327,7 +342,7 @@ for (let i = 0; i < plugins.length && desktopItems.length < DESKTOP_FIRST_WAVE_M const suggestItems = plugins.map((p) => goJSON({ type: "plugin", - id: p.full_name, + id: p.id, label: p.name, // description 为空时退回 "@owner" sub: p.description === "" ? "@" + p.owner : p.description, diff --git a/scripts/gen-plugins-real.mjs b/scripts/gen-plugins-real.mjs index 8bd22df..32ca9a1 100644 --- a/scripts/gen-plugins-real.mjs +++ b/scripts/gen-plugins-real.mjs @@ -15,7 +15,9 @@ import { writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { catalogItemSQL } from "./lib/catalog-sql.mjs"; import { openDb } from "./lib/db.mjs"; +import { catalogIdentity } from "./lib/workspaces.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const out = resolve(root, "src/lib/plugins-real.ts"); @@ -25,7 +27,8 @@ const outPicks = resolve(root, "src/lib/home-picks.ts"); const client = openDb(); const rs = await client.execute( - `SELECT full_name, name, owner, url, description, tags, language, stars, pushed_at, archived, category, score, + `SELECT full_name, repository_full_name, package_path, + name, owner, url, description, tags, language, stars, pushed_at, archived, category, score, is_featured, featured_boost, is_insider, is_official, is_risky, risk_note, dl_pkg, dl_npm_total, dl_mirror_total, dl_release_total, dl_manual_total, dl_manual_note, @@ -33,13 +36,17 @@ const rs = await client.execute( npm_latest_version FROM plugins WHERE is_present = 1 AND is_offtopic = 0 + AND ${catalogItemSQL("plugins")} ORDER BY is_risky ASC, is_featured * featured_boost DESC, stars DESC, full_name`, ); const plugins = rs.rows.map((r) => ({ + id: catalogIdentity(r), name: String(r.name), owner: String(r.owner), fullName: String(r.full_name), + repositoryFullName: r.repository_full_name == null ? null : String(r.repository_full_name), + packagePath: r.package_path == null ? null : String(r.package_path), url: String(r.url), description: String(r.description ?? ""), tags: JSON.parse(String(r.tags ?? "[]")), @@ -126,7 +133,7 @@ function installLiteral(i) { } const line = (p) => - ` { name: ${JSON.stringify(p.name)}, owner: ${JSON.stringify(p.owner)}, fullName: ${JSON.stringify(p.fullName)}, url: ${JSON.stringify(p.url)}, description: ${JSON.stringify(p.description)}, tags: [${p.tags.map((t) => JSON.stringify(t)).join(",")}], language: ${JSON.stringify(p.language)}, stars: ${p.stars}, pushedAt: ${JSON.stringify(p.pushedAt)}, archived: ${p.archived}, category: ${JSON.stringify(p.category)}, score: ${p.score}, isFeatured: ${p.isFeatured}${p.featuredBoost ? "" : ", featuredBoost: false"}, isInsider: ${p.isInsider}, isOfficial: ${p.isOfficial}, isRisky: ${p.isRisky}, riskNote: ${JSON.stringify(p.riskNote)}${ + ` { name: ${JSON.stringify(p.name)}, owner: ${JSON.stringify(p.owner)}, fullName: ${JSON.stringify(p.fullName)}${p.id === p.fullName ? "" : `, id: ${JSON.stringify(p.id)}`}${p.repositoryFullName ? `, repositoryFullName: ${JSON.stringify(p.repositoryFullName)}` : ""}${p.packagePath ? `, packagePath: ${JSON.stringify(p.packagePath)}` : ""}, url: ${JSON.stringify(p.url)}, description: ${JSON.stringify(p.description)}, tags: [${p.tags.map((t) => JSON.stringify(t)).join(",")}], language: ${JSON.stringify(p.language)}, stars: ${p.stars}, pushedAt: ${JSON.stringify(p.pushedAt)}, archived: ${p.archived}, category: ${JSON.stringify(p.category)}, score: ${p.score}, isFeatured: ${p.isFeatured}${p.featuredBoost ? "" : ", featuredBoost: false"}, isInsider: ${p.isInsider}, isOfficial: ${p.isOfficial}, isRisky: ${p.isRisky}, riskNote: ${JSON.stringify(p.riskNote)}${ p.downloads ? `, downloads: { channel: ${JSON.stringify(p.downloads.channel)}, total: ${p.downloads.total}${ p.downloads.note ? `, note: ${JSON.stringify(p.downloads.note)}` : "" @@ -272,7 +279,8 @@ const railRows = ( FROM plugins p LEFT JOIN base b ON b.full_name = p.full_name LEFT JOIN plugin_snapshots bs ON bs.full_name = b.full_name AND bs.snapshot_date = b.d - WHERE p.is_present = 1 AND p.is_offtopic = 0 AND p.is_risky = 0 AND p.archived = 0`, + WHERE p.is_present = 1 AND p.is_offtopic = 0 AND p.is_risky = 0 AND p.archived = 0 + AND ${catalogItemSQL("p")}`, ) ).rows; diff --git a/scripts/lib/catalog-sql.mjs b/scripts/lib/catalog-sql.mjs new file mode 100644 index 0000000..6bdcda8 --- /dev/null +++ b/scripts/lib/catalog-sql.mjs @@ -0,0 +1,6 @@ +export function catalogItemSQL(alias) { + return `(${alias}.repository_full_name IS NOT NULL OR ${alias}.has_bundle = 1 OR NOT EXISTS ( + SELECT 1 FROM plugins child + WHERE child.repository_full_name = ${alias}.full_name AND child.is_present = 1 + ))`; +} diff --git a/scripts/lib/plugin-images.mjs b/scripts/lib/plugin-images.mjs index 918e087..a3a4ba1 100644 --- a/scripts/lib/plugin-images.mjs +++ b/scripts/lib/plugin-images.mjs @@ -133,7 +133,7 @@ function normalizePath(path) { * @param branch 默认分支;用 "HEAD" 可以免去查询默认分支名(main/master 之争) * @returns 可下载的绝对 URL;无法解析(data:、锚点等)返回 null */ -export function resolveImageUrl(raw, fullName, branch = "HEAD") { +export function resolveImageUrl(raw, fullName, branch = "HEAD", basePath = "") { if (typeof raw !== "string") return null; const url = raw.trim(); if (!url || url.startsWith("#") || /^(data|mailto|javascript):/i.test(url)) { @@ -161,7 +161,10 @@ export function resolveImageUrl(raw, fullName, branch = "HEAD") { if (!fullName || !/^[^/]+\/[^/]+$/.test(fullName)) return null; // 根相对(/assets/x.png)在 README 语境下指的是仓库根,不是站点根 - const path = normalizePath(url.split(/[?#]/, 1)[0].replace(/^\//, "")); + const relative = url.split(/[?#]/, 1)[0]; + const path = normalizePath( + relative.startsWith("/") ? relative.slice(1) : `${basePath ? `${basePath}/` : ""}${relative}`, + ); if (!path) return null; return `https://raw.githubusercontent.com/${fullName}/${branch}/${path}`; } @@ -198,10 +201,14 @@ function scoreCandidate({ url, alt }) { * * @returns {{url: string, sourceUrl: string, alt: string, score: number} | null} */ -export function pickPluginImage(markdown, fullName, branch = "HEAD") { +export function pickPluginImage(markdown, fullName, branch = "HEAD", basePath = "") { const candidates = extractImageCandidates(markdown) .filter((c) => !isBadge(c.url)) - .map((c, order) => ({ ...c, order, resolved: resolveImageUrl(c.url, fullName, branch) })) + .map((c, order) => ({ + ...c, + order, + resolved: resolveImageUrl(c.url, fullName, branch, basePath), + })) .filter((c) => c.resolved && !isBadge(c.resolved)); if (candidates.length === 0) return null; diff --git a/scripts/lib/plugin-images.test.mjs b/scripts/lib/plugin-images.test.mjs index e8fdacf..473a2d8 100644 --- a/scripts/lib/plugin-images.test.mjs +++ b/scripts/lib/plugin-images.test.mjs @@ -79,6 +79,13 @@ test("resolveImageUrl:相对路径拼回 raw 域名", () => { ); }); +test("resolveImageUrl:workspace README 的相对路径以子包目录为基准", () => { + assert.equal( + resolveImageUrl("./preview.png", "o/r", "HEAD", "packages/dsh-a2a"), + "https://raw.githubusercontent.com/o/r/HEAD/packages/dsh-a2a/preview.png", + ); +}); + test("resolveImageUrl:README 里的根相对指向仓库根,不是站点根", () => { assert.equal( resolveImageUrl("/assets/x.png", "o/r"), diff --git a/scripts/lib/workspaces.mjs b/scripts/lib/workspaces.mjs new file mode 100644 index 0000000..395212d --- /dev/null +++ b/scripts/lib/workspaces.mjs @@ -0,0 +1,88 @@ +import { matchesGlob, posix } from "node:path"; + +const SAFE_DIRECTORY = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/; +const SAFE_PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; + +function workspacePatterns(pkg) { + return Array.isArray(pkg?.workspaces) + ? pkg.workspaces + : Array.isArray(pkg?.workspaces?.packages) + ? pkg.workspaces.packages + : []; +} + +function safePattern(value) { + if (typeof value !== "string") return null; + const negated = value.startsWith("!"); + const pattern = (negated ? value.slice(1) : value) + .trim() + .replace(/^\.\//, "") + .replace(/\/$/, ""); + if ( + !pattern || + pattern.startsWith("/") || + pattern.includes("\\") || + pattern.split("/").includes("..") + ) { + return null; + } + return { negated, pattern }; +} + +/** Apply package.json workspace globs to repository package.json paths. */ +export function workspaceDirectories(pkg, manifestPaths) { + const directories = [...new Set( + manifestPaths + .filter((path) => path.endsWith("/package.json")) + .map((path) => posix.dirname(path)) + .filter((path) => SAFE_DIRECTORY.test(path)), + )]; + const selected = new Set(); + let complete = true; + for (const raw of workspacePatterns(pkg)) { + const parsed = safePattern(raw); + if (!parsed) { + complete = false; + continue; + } + for (const directory of directories) { + if (!matchesGlob(directory, parsed.pattern)) continue; + if (parsed.negated) selected.delete(directory); + else selected.add(directory); + } + } + return { directories: [...selected].sort(), complete }; +} + +/** Internal owner/repo route key; the public catalog identity remains the package name. */ +export function workspaceItemFullName(repositoryFullName, packageName) { + if (!/^[^/]+\/[^/]+$/.test(repositoryFullName) || !SAFE_PACKAGE_NAME.test(packageName)) { + return null; + } + const suffix = packageName.startsWith("@") + ? packageName.slice(1).replace("/", "~") + : packageName; + return `${repositoryFullName}~${suffix}`; +} + +/** Resolve one catalog row to its real repository and optional package directory. */ +export function pluginSource(row) { + return { + fullName: String(row.full_name), + repositoryFullName: row.repository_full_name == null + ? String(row.full_name) + : String(row.repository_full_name), + packagePath: row.package_path == null ? null : String(row.package_path), + }; +} + +/** Public catalog identity: package name for workspace children, route key otherwise. */ +export function catalogIdentity(row) { + return row.repository_full_name == null || row.pkg_name == null + ? String(row.full_name) + : String(row.pkg_name); +} + +export function repositoryPath(packagePath, file) { + return packagePath ? `${packagePath}/${file}` : file; +} diff --git a/scripts/lib/workspaces.test.mjs b/scripts/lib/workspaces.test.mjs new file mode 100644 index 0000000..736586f --- /dev/null +++ b/scripts/lib/workspaces.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + catalogIdentity, + pluginSource, + repositoryPath, + workspaceDirectories, + workspaceItemFullName, +} from "./workspaces.mjs"; + +const manifests = [ + "packages/a/package.json", + "packages/b/package.json", + "packages/a/plugins/nested/package.json", + "tools/plugin/package.json", +]; + +test("workspaceDirectories supports package.json workspace forms", () => { + assert.deepEqual(workspaceDirectories({ workspaces: ["packages/*"] }, manifests), { + directories: ["packages/a", "packages/b"], + complete: true, + }); + assert.deepEqual( + workspaceDirectories({ workspaces: { packages: ["tools/*"] } }, manifests), + { directories: ["tools/plugin"], complete: true }, + ); +}); + +test("workspaceDirectories supports recursive, nested, brace, extglob, and exclusion patterns", () => { + for (const pattern of ["packages/**", "packages/*/plugins/*", "packages/{a,b}", "packages/@(a|b)"]) { + assert.ok(workspaceDirectories({ workspaces: [pattern] }, manifests).directories.length > 0, pattern); + } + assert.deepEqual( + workspaceDirectories({ workspaces: ["packages/**", "!packages/a/plugins/*"] }, manifests), + { directories: ["packages/a", "packages/b"], complete: true }, + ); +}); + +test("workspaceDirectories rejects unsafe declarations without authorizing cleanup", () => { + assert.deepEqual(workspaceDirectories({ workspaces: ["../outside"] }, manifests), { + directories: [], + complete: false, + }); +}); + +test("workspaceItemFullName creates a stable internal route key", () => { + assert.equal( + workspaceItemFullName("TGYD-helige/dsh-plugins", "@amaster.ai/dsh-a2a"), + "TGYD-helige/dsh-plugins~amaster.ai~dsh-a2a", + ); +}); + +test("pluginSource and repositoryPath resolve package-relative source files", () => { + assert.deepEqual( + pluginSource({ full_name: "route/key", repository_full_name: "owner/repo", package_path: "packages/a" }), + { fullName: "route/key", repositoryFullName: "owner/repo", packagePath: "packages/a" }, + ); + assert.equal(repositoryPath("packages/a", "README.md"), "packages/a/README.md"); + assert.equal(repositoryPath(null, "README.md"), "README.md"); +}); + +test("catalogIdentity uses the exact workspace package name", () => { + assert.equal(catalogIdentity({ full_name: "owner/repo" }), "owner/repo"); + assert.equal(catalogIdentity({ + full_name: "owner/repo~scope~plugin", + repository_full_name: "owner/repo", + pkg_name: "@scope/plugin", + }), "@scope/plugin"); +}); diff --git a/scripts/probe-badge.mjs b/scripts/probe-badge.mjs index d65a974..a595e69 100644 --- a/scripts/probe-badge.mjs +++ b/scripts/probe-badge.mjs @@ -26,6 +26,7 @@ * 链的是 GitHub)判成了「完全没提」。最热的线索差点被归进最冷的一档。 */ import { openDb } from "./lib/db.mjs"; +import { pluginSource, repositoryPath } from "./lib/workspaces.mjs"; const CONCURRENCY = 8; const DEFAULT_STALE_DAYS = 30; @@ -98,9 +99,10 @@ async function tryFetch(url, init, attempts = 3) { * 返回 { text } 表示读到了(text 可能为空串——仓库有 README 但内容为空); * 返回 null 表示**没读到**,与「读到了但没有徽章」是两回事。 */ -async function fetchReadme(fullName) { +async function fetchReadme(fullName, packagePath = null) { for (const file of ["README.md", "readme.md", "README.zh-CN.md", "README.rst"]) { - const res = await tryFetch(`https://raw.githubusercontent.com/${fullName}/HEAD/${file}`); + const path = repositoryPath(packagePath, file); + const res = await tryFetch(`https://raw.githubusercontent.com/${fullName}/HEAD/${path}`); if (!res) return null; // 连接层失败:判「没测到」,不是「没有 README」 if (!res.ok) continue; // 404:换下一个文件名 try { @@ -147,7 +149,7 @@ for (const sql of [ const where = ["is_present = 1", "is_offtopic = 0", "is_risky = 0"]; const args = []; if (only.length) { - where.push(`full_name IN (${only.map(() => "?").join(",")})`); + where.push(`COALESCE(repository_full_name, full_name) IN (${only.map(() => "?").join(",")})`); args.push(...only); } else { if (minStars > 0) where.push(`stars >= ${minStars}`); @@ -160,7 +162,8 @@ if (only.length) { const rows = ( await client.execute({ - sql: `SELECT full_name, stars, has_badge, badge_first_seen_at + sql: `SELECT full_name, repository_full_name, package_path, + stars, has_badge, badge_first_seen_at FROM plugins WHERE ${where.join(" AND ")} ORDER BY stars DESC${limit > 0 ? ` LIMIT ${limit}` : ""}`, args, @@ -171,8 +174,8 @@ console.log(`待探测 ${rows.length} 个仓库(并发 ${CONCURRENCY})…`); let done = 0; const results = await mapPool(rows, CONCURRENCY, async (r) => { - const fullName = String(r.full_name); - const readme = await fetchReadme(fullName); + const { fullName, repositoryFullName, packagePath } = pluginSource(r); + const readme = await fetchReadme(repositoryFullName, packagePath); done++; if (done % 100 === 0) console.log(` …${done}/${rows.length}`); if (!readme) return { fullName, skipped: true }; diff --git a/scripts/probe-downloads.mjs b/scripts/probe-downloads.mjs index bd5a3c6..714b35b 100644 --- a/scripts/probe-downloads.mjs +++ b/scripts/probe-downloads.mjs @@ -30,6 +30,7 @@ import { execFileSync } from "node:child_process"; import { openDb } from "./lib/db.mjs"; import { normalizeNpmRepository, npmRepoBacklink } from "./lib/install.mjs"; +import { pluginSource, repositoryPath } from "./lib/workspaces.mjs"; import { NPM_STATS_EPOCH, downloadStatus, @@ -129,8 +130,9 @@ function gh(path) { * 仓库根 package.json 里的包名。走 raw 而不是 contents API:不吃 core 配额, * 也不需要凭据。私有包(private: true)不返回——它不可能有公开下载量。 */ -async function repoPackageName(fullName) { - const res = await tryFetch(`https://raw.githubusercontent.com/${fullName}/HEAD/package.json`); +async function repoPackageName(fullName, packagePath = null) { + const path = repositoryPath(packagePath, "package.json"); + const res = await tryFetch(`https://raw.githubusercontent.com/${fullName}/HEAD/${path}`); if (!res) return { ok: false, name: null }; if (!res.ok) return { ok: true, name: null }; try { @@ -226,7 +228,7 @@ async function mapPool(items, poolSize, fn) { // ---------- 单仓探测 ---------- async function probe(row, today) { - const fullName = String(row.full_name); + const { fullName, repositoryFullName, packagePath } = pluginSource(row); const result = { fullName, stars: Number(row.stars ?? 0), @@ -241,7 +243,7 @@ async function probe(row, today) { // 1. 包名:库里 probe:install 探过就直接用,没探过现抓一次 package.json let pkg = row.pkg_name ? String(row.pkg_name) : null; if (!pkg) { - const found = await repoPackageName(fullName); + const found = await repoPackageName(repositoryFullName, packagePath); if (!found.ok) { result.failed = true; return result; @@ -259,7 +261,7 @@ async function probe(row, today) { } if (!doc.published) { unpublished = true; - } else if (!npmRepoBacklink(fullName, doc.repository)) { + } else if (!npmRepoBacklink(repositoryFullName, doc.repository)) { // 记下真正的归属方,运营一眼能看出是「撞了个老包」还是「被人抢注」 const owner = normalizeNpmRepository(doc.repository) ?? "无 repository 字段"; result.note = `name-taken:${pkg}→${owner}`; @@ -271,7 +273,7 @@ async function probe(row, today) { } // 3. Release 资产:与 npm 互不排斥,桌面端插件两边都有 - result.releaseTotal = await releaseDownloads(fullName); + result.releaseTotal = await releaseDownloads(repositoryFullName); result.status = downloadStatus({ npmTotal: result.npmTotal, @@ -315,7 +317,7 @@ const where = ["is_present = 1", "is_risky = 0"]; const args = []; if (!includeOfftopic) where.push("is_offtopic = 0"); if (only.length) { - where.push(`full_name IN (${only.map(() => "?").join(",")})`); + where.push(`COALESCE(repository_full_name, full_name) IN (${only.map(() => "?").join(",")})`); args.push(...only); } else { if (minStars > 0) where.push(`stars >= ${minStars}`); @@ -326,7 +328,8 @@ if (only.length) { const rows = ( await client.execute({ - sql: `SELECT full_name, stars, pkg_name, dl_npm_total, dl_mirror_total, dl_release_total + sql: `SELECT full_name, repository_full_name, package_path, + stars, pkg_name, dl_npm_total, dl_mirror_total, dl_release_total FROM plugins WHERE ${where.join(" AND ")} ORDER BY stars DESC${limit > 0 ? ` LIMIT ${limit}` : ""}`, args, diff --git a/scripts/probe-install.mjs b/scripts/probe-install.mjs index 82762bc..5fd3d36 100644 --- a/scripts/probe-install.mjs +++ b/scripts/probe-install.mjs @@ -24,6 +24,7 @@ */ import { execFileSync } from "node:child_process"; import { openDb } from "./lib/db.mjs"; +import { classifyPlugin } from "./lib/categories.mjs"; import { buildEntryPath, @@ -43,6 +44,12 @@ import { mergeReleaseProbe, probeTimestamp, } from "./lib/github-release-probe.mjs"; +import { + pluginSource, + repositoryPath, + workspaceDirectories, + workspaceItemFullName, +} from "./lib/workspaces.mjs"; const CONCURRENCY = 8; const DEFAULT_STALE_DAYS = 7; @@ -159,16 +166,28 @@ async function tryFetch(url, init, attempts = 3) { * 404 是「这仓库没有 package.json」的事实,429/5xx/网络失败则什么都没证明。 * 解析失败按 absent 处理——文件确实拿到了,只是不是合法 JSON。 */ -async function fetchManifest(fullName) { +const manifestCache = new Map(); + +async function fetchManifest(fullName, packagePath = null) { + const key = `${fullName}:${packagePath ?? ""}`; + if (manifestCache.has(key)) return manifestCache.get(key); const res = await tryFetch( - `https://raw.githubusercontent.com/${fullName}/HEAD/package.json`, + `https://raw.githubusercontent.com/${fullName}/HEAD/${repositoryPath(packagePath, "package.json")}`, ); const outcome = fetchOutcome(res); - if (outcome !== "ok") return { outcome, pkg: null }; + if (outcome !== "ok") { + const result = { outcome, pkg: null }; + manifestCache.set(key, result); + return result; + } try { - return { outcome: "ok", pkg: JSON.parse(await res.text()) }; + const result = { outcome: "ok", pkg: JSON.parse(await res.text()) }; + manifestCache.set(key, result); + return result; } catch { - return { outcome: "absent", pkg: null }; + const result = { outcome: "absent", pkg: null }; + manifestCache.set(key, result); + return result; } } @@ -216,9 +235,10 @@ async function fetchNpmPublished(name, isPrivate) { const EXACT_STABLE_VERSION = /^\d+\.\d+\.\d+$/; /** README 原文。文件名各家不一,按常见顺序试;都没有就返回 null。 */ -async function fetchReadme(fullName) { +async function fetchReadme(fullName, packagePath = null) { for (const file of ["README.md", "readme.md", "README.zh-CN.md", "README.rst"]) { - const res = await tryFetch(`https://raw.githubusercontent.com/${fullName}/HEAD/${file}`); + const path = repositoryPath(packagePath, file); + const res = await tryFetch(`https://raw.githubusercontent.com/${fullName}/HEAD/${path}`); if (!res?.ok) continue; try { return await res.text(); @@ -230,18 +250,19 @@ async function fetchReadme(fullName) { } /** 构建产物是不是已经提交进仓库了——是的话 git 直装拿到的源码就是能跑的。 */ -async function fetchEntryCommitted(fullName, entryPath) { +async function fetchEntryCommitted(fullName, packagePath, entryPath) { if (!entryPath) return false; + const path = repositoryPath(packagePath, entryPath); const res = await tryFetch( - `https://raw.githubusercontent.com/${fullName}/HEAD/${entryPath}`, + `https://raw.githubusercontent.com/${fullName}/HEAD/${path}`, { method: "HEAD" }, ); return Boolean(res?.ok); } -async function probe(fullName, previous) { +async function probe(fullName, packagePath, previous) { const previousRelease = previous.release; - const fetched = await fetchManifest(fullName); + const fetched = await fetchManifest(fullName, packagePath); const manifestProbe = mergeManifestProbe({ outcome: fetched.outcome, facts: manifestFacts(fetched.pkg), @@ -260,10 +281,10 @@ async function probe(fullName, previous) { sleep: budgetedSleep, }), manifest.hasBundle && manifestProbe.complete - ? fetchReadme(fullName).then((md) => readmeInstallHint(md)?.cmd ?? null) + ? fetchReadme(fullName, packagePath).then((md) => readmeInstallHint(md)?.cmd ?? null) : previous.manifest.readmeCmd, manifest.hasBundle && manifestProbe.complete - ? fetchEntryCommitted(fullName, buildEntryPath(fetched.pkg)) + ? fetchEntryCommitted(fullName, packagePath, buildEntryPath(fetched.pkg)) : Boolean(previous.manifest.entryCommitted), ]); const npmProbe = mergeNpmProbe({ @@ -339,6 +360,8 @@ for (const sql of [ `ALTER TABLE plugins ADD COLUMN npm_repo_backlink INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE plugins ADD COLUMN npm_desktop_installable INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE plugins ADD COLUMN npm_repo_directory TEXT`, + `ALTER TABLE plugins ADD COLUMN repository_full_name TEXT`, + `ALTER TABLE plugins ADD COLUMN package_path TEXT`, ]) { try { await client.execute(sql); @@ -347,7 +370,148 @@ for (const sql of [ } } -let sql = `SELECT full_name, pkg_name, pkg_version, pkg_private, has_bundle, has_prepare, +/** One recursive tree request exposes every candidate workspace manifest. */ +async function fetchManifestTree(fullName) { + const headers = { + Accept: "application/vnd.github+json", + "user-agent": "dshfind-install-probe (+https://dshfind.com)", + ...(token ? { authorization: `Bearer ${token}` } : {}), + }; + const res = await tryFetch(`https://api.github.com/repos/${fullName}/git/trees/HEAD?recursive=1`, { headers }); + const outcome = fetchOutcome(res); + if (outcome !== "ok") return { outcome, paths: null, complete: false }; + try { + const body = await res.json(); + if (!Array.isArray(body?.tree)) return { outcome: "unknown", paths: null, complete: false }; + return { + outcome: "ok", + paths: body.tree + .filter((entry) => entry?.type === "blob" && typeof entry.path === "string") + .map((entry) => entry.path), + complete: body.truncated !== true, + }; + } catch { + return { outcome: "unknown", paths: null, complete: false }; + } +} + +async function writeWorkspaceItems(parent, items, complete) { + const now = new Date().toISOString(); + const upserts = items.map(({ fullName, packagePath, pkg }) => ({ + sql: `INSERT INTO plugins + (full_name, name, owner, url, description, tags, language, stars, + contributors, pushed_at, archived, first_seen_at, last_synced_at, is_present, + category, is_insider, repository_full_name, package_path, pkg_name, + has_bundle, is_plugin) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, 1, 1) + ON CONFLICT(full_name) DO UPDATE SET + name = excluded.name, owner = excluded.owner, url = excluded.url, + description = excluded.description, tags = excluded.tags, language = excluded.language, + stars = excluded.stars, contributors = excluded.contributors, + pushed_at = excluded.pushed_at, archived = excluded.archived, + last_synced_at = excluded.last_synced_at, is_present = 1, + category = CASE WHEN plugins.category_manual = 1 THEN plugins.category ELSE excluded.category END, + repository_full_name = excluded.repository_full_name, + package_path = excluded.package_path, pkg_name = excluded.pkg_name, + has_bundle = 1, + is_plugin = CASE WHEN plugins.is_plugin_manual = 1 THEN plugins.is_plugin ELSE 1 END`, + args: [ + fullName, + pkg.name, + parent.owner, + parent.url, + typeof pkg.description === "string" ? pkg.description.trim() : String(parent.description ?? ""), + parent.tags, + parent.language, + parent.stars, + parent.contributors, + parent.pushed_at, + parent.archived, + parent.first_seen_at || now, + now, + classifyPlugin({ + name: pkg.name, + description: typeof pkg.description === "string" ? pkg.description : "", + tags: JSON.parse(String(parent.tags ?? "[]")), + }), + parent.is_insider, + parent.full_name, + packagePath, + pkg.name, + ], + })); + if (upserts.length) await client.batch(upserts, "write"); + if (complete) { + await client.execute({ + sql: `UPDATE plugins SET is_present = 0 + WHERE repository_full_name = ? + AND full_name NOT IN (SELECT value FROM json_each(?))`, + args: [parent.full_name, JSON.stringify(items.map((item) => item.fullName))], + }); + } +} + +async function discoverWorkspaceItems(parent) { + const repositoryFullName = String(parent.full_name); + const root = await fetchManifest(repositoryFullName); + if (root.outcome !== "ok") return; + + const packageWorkspaces = Array.isArray(root.pkg?.workspaces) + ? root.pkg.workspaces + : root.pkg?.workspaces?.packages; + if (!Array.isArray(packageWorkspaces) || packageWorkspaces.length === 0) { + await writeWorkspaceItems(parent, [], true); + return; + } + + const tree = await fetchManifestTree(repositoryFullName); + if (tree.outcome !== "ok") return; + const expanded = workspaceDirectories(root.pkg, tree.paths); + let complete = tree.complete && expanded.complete; + + const manifests = await mapPool(expanded.directories, CONCURRENCY, async (packagePath) => ({ + packagePath, + fetched: await fetchManifest(repositoryFullName, packagePath), + })); + const items = []; + for (const { packagePath, fetched } of manifests) { + if (fetched.outcome === "unknown") complete = false; + const facts = manifestFacts(fetched.pkg); + if (!facts.hasBundle || !facts.pkgName) continue; + const fullName = workspaceItemFullName(repositoryFullName, facts.pkgName); + if (fullName) items.push({ fullName, packagePath, pkg: fetched.pkg }); + } + await writeWorkspaceItems(parent, items, complete); +} + +if (!rederive) { + let discoverySql = `SELECT full_name, owner, url, description, tags, language, stars, + contributors, pushed_at, archived, first_seen_at, is_insider, + install_probed_at + FROM plugins + WHERE is_present = 1 AND is_offtopic = 0 AND repository_full_name IS NULL`; + const discoveryArgs = []; + if (only.length) { + discoverySql += ` AND lower(full_name) IN (${only.map(() => "?").join(",")})`; + discoveryArgs.push(...only.map((s) => s.toLowerCase())); + } else if (!all) { + const cutoff = new Date(Date.now() - staleDays * 86400_000).toISOString(); + discoverySql += ` AND (install_probed_at IS NULL OR install_probed_at < ?)`; + discoveryArgs.push(cutoff); + } + if (!only.length && minStars > 0) { + discoverySql += ` AND stars >= ?`; + discoveryArgs.push(minStars); + } + const roots = (await client.execute({ sql: discoverySql, args: discoveryArgs })).rows; + if (roots.length) { + console.log(`发现 ${roots.length} 个仓库的 workspace 子包…`); + await mapPool(roots, CONCURRENCY, discoverWorkspaceItems); + } +} + +let sql = `SELECT full_name, repository_full_name, package_path, + pkg_name, pkg_version, pkg_private, has_bundle, has_prepare, entry_needs_build, entry_committed, npm_published, readme_install_cmd, release_tgz_url, release_tag, release_prerelease, release_asset_name, release_asset_size, release_asset_digest, release_etag, install_probed_at, @@ -356,7 +520,7 @@ let sql = `SELECT full_name, pkg_name, pkg_version, pkg_private, has_bundle, has FROM plugins WHERE is_present = 1 AND is_offtopic = 0`; const args = []; if (only.length) { - sql += ` AND lower(full_name) IN (${only.map(() => "?").join(",")})`; + sql += ` AND lower(COALESCE(repository_full_name, full_name)) IN (${only.map(() => "?").join(",")})`; args.push(...only.map((s) => s.toLowerCase())); } else if (rederive) { sql += ` AND install_probed_at IS NOT NULL`; @@ -390,7 +554,7 @@ let done = 0; /** 一次探一片、写一片。见下面 SLICE 的说明。 */ async function probeRow(r) { - const fullName = String(r.full_name); + const { fullName: itemFullName, repositoryFullName, packagePath } = pluginSource(r); const previousRelease = { releaseTgzUrl: r.release_tgz_url == null ? null : String(r.release_tgz_url), releaseTag: r.release_tag == null ? null : String(r.release_tag), @@ -434,14 +598,14 @@ async function probeRow(r) { }, complete: rederive, } - : await probe(fullName, { + : await probe(repositoryFullName, packagePath, { manifest: previousManifest, npm: previousNpm, release: previousRelease, }); if (!rederive && ++done % 100 === 0) console.log(` …${done}/${rows.length}`); return { - fullName, + fullName: itemFullName, // 到点跳过的行一个字节都不写:facts 原样返回没有新信息,而 derived 是拿 // 「空的上一轮事实」重算出来的,会得出 not-installable,再经 writeResults // 的 is_plugin 那一列把「未知」改写成「确认非插件」。写回去比不写糟得多。 @@ -449,7 +613,10 @@ async function probeRow(r) { complete: result.complete, facts: result.facts, was: previousNpm, // 用来对比桌面端市场契约变了什么 - derived: deriveInstall({ fullName, ...result.facts }), + derived: deriveInstall({ + fullName: repositoryFullName, + ...result.facts, + }), // 重算模式不改时间;release API 失败则保留旧时间,让下一轮立即重试。 probedAt: probeTimestamp({ rederive, diff --git a/scripts/sync-plugins-db.mjs b/scripts/sync-plugins-db.mjs index d636249..e157f1e 100644 --- a/scripts/sync-plugins-db.mjs +++ b/scripts/sync-plugins-db.mjs @@ -344,6 +344,8 @@ const MIGRATIONS = [ `ALTER TABLE plugins ADD COLUMN risk_note TEXT`, `ALTER TABLE plugins ADD COLUMN is_plugin INTEGER`, // 1 = 确认是 DSH 插件(dsh.bundle),0 = 确认非插件,NULL = 未探测 `ALTER TABLE plugins ADD COLUMN is_plugin_manual INTEGER NOT NULL DEFAULT 0`, // 1 = 人工标记,自动管道不覆盖 + `ALTER TABLE plugins ADD COLUMN repository_full_name TEXT`, // monorepo 子包所属 owner/repo;根条目为 NULL + `ALTER TABLE plugins ADD COLUMN package_path TEXT`, // 子包相对仓库根目录 ]; // ---------- 主流程 ---------- @@ -443,12 +445,33 @@ try { await client.batch(stmts.slice(i, i + 100), "write"); } + // workspace 子包复用仓库的 GitHub 事实;安装与包清单事实仍由 probe-install 单独维护。 + await client.execute(`UPDATE plugins AS child SET + owner = parent.owner, url = parent.url, tags = parent.tags, language = parent.language, + stars = parent.stars, contributors = parent.contributors, pushed_at = parent.pushed_at, + archived = parent.archived, last_synced_at = parent.last_synced_at, + is_present = CASE WHEN parent.is_present = 0 THEN 0 ELSE child.is_present END, + is_insider = CASE WHEN parent.is_insider = 1 THEN 1 ELSE child.is_insider END + FROM plugins AS parent + WHERE child.repository_full_name = parent.full_name`); + + await client.execute({ + sql: `INSERT OR REPLACE INTO plugin_snapshots + (full_name, snapshot_date, stars, contributors, pushed_at) + SELECT child.full_name, ?, parent.stars, parent.contributors, parent.pushed_at + FROM plugins child + JOIN plugins parent ON parent.full_name = child.repository_full_name + WHERE child.is_present = 1`, + args: [today], + }); + // 全部 upsert 成功后才软删——中途崩溃不会把站点清空。 // 点名轮次没有全量名单,跳过(见 ONLY 的注释)。 if (!ONLY.length) { await client.execute({ sql: `UPDATE plugins SET is_present = 0 - WHERE full_name NOT IN (SELECT value FROM json_each(?))`, + WHERE COALESCE(repository_full_name, full_name) + NOT IN (SELECT value FROM json_each(?))`, args: [JSON.stringify(repos.map((r) => r.full_name))], }); } diff --git a/server/internal/cache/plugins.go b/server/internal/cache/plugins.go index c0c42ce..4c30805 100644 --- a/server/internal/cache/plugins.go +++ b/server/internal/cache/plugins.go @@ -136,7 +136,7 @@ func (c *Cache) Refresh(ctx context.Context) error { snap.suggestIdx[i] = suggestEntry{ sug: Suggestion{ Type: "plugin", - ID: p.FullName, + ID: p.Identity(), Label: p.Name, Sub: sub, // 站内详情页相对路径,locale 前缀由前端 next-intl router 补;与现契约一致 diff --git a/server/internal/httpapi/graphql.go b/server/internal/httpapi/graphql.go index f17574f..9db2dea 100644 --- a/server/internal/httpapi/graphql.go +++ b/server/internal/httpapi/graphql.go @@ -658,11 +658,26 @@ func (e graphExecutor) selectPlugin(plugin store.Plugin, selection []graphField, return nil, err } result[key] = "Plugin" - case "id", "fullName": + case "id": + if err := graphRejectScalarSelection("Plugin", field); err != nil { + return nil, err + } + result[key] = plugin.Identity() + case "fullName": if err := graphRejectScalarSelection("Plugin", field); err != nil { return nil, err } result[key] = plugin.FullName + case "repositoryFullName": + if err := graphRejectScalarSelection("Plugin", field); err != nil { + return nil, err + } + result[key] = graphStringPointer(plugin.RepositoryFullName) + case "packagePath": + if err := graphRejectScalarSelection("Plugin", field); err != nil { + return nil, err + } + result[key] = graphStringPointer(plugin.PackagePath) case "name": if err := graphRejectScalarSelection("Plugin", field); err != nil { return nil, err diff --git a/server/internal/httpapi/graphql_schema.graphql b/server/internal/httpapi/graphql_schema.graphql index 9abb68c..47058f5 100644 --- a/server/internal/httpapi/graphql_schema.graphql +++ b/server/internal/httpapi/graphql_schema.graphql @@ -80,9 +80,14 @@ type PageInfo { } type Plugin { - """稳定标识,等同于 fullName。""" + """公开目录身份;workspace 子包使用精确包名。""" id: ID! + """内部行与网页路由键;单包仓库等同于 id。""" fullName: String! + """workspace 子包所属的真实 GitHub owner/repo;单包仓库为 null。""" + repositoryFullName: String + """workspace 子包相对仓库根目录;单包仓库为 null。""" + packagePath: String name: String! owner: String! """GitHub 仓库页面 URL;请迁移到语义明确的 repositoryUrl。""" diff --git a/server/internal/httpapi/graphql_test.go b/server/internal/httpapi/graphql_test.go index 66553ef..7264ee2 100644 --- a/server/internal/httpapi/graphql_test.go +++ b/server/internal/httpapi/graphql_test.go @@ -96,16 +96,19 @@ func TestGraphQLPluginMappingVariablesAndFragments(t *testing.T) { scoredAt := "2026-08-16T00:00:00Z" scoreVersion := "2026-08-17.1" probedAt := "2026-08-15T00:00:00Z" + repositoryFullName := "owner/repo" + packagePath := "packages/memory" source := graphTestSource{plugins: []store.Plugin{{ - FullName: "owner/memory", Name: "memory", Owner: "owner", URL: "https://legacy.example/memory", RepositoryURL: "https://github.com/owner/memory", + FullName: "owner/repo~memory", ID: "@owner/memory", RepositoryFullName: &repositoryFullName, PackagePath: &packagePath, + Name: "@owner/memory", Owner: "owner", URL: "https://legacy.example/memory", RepositoryURL: "https://github.com/owner/repo", Description: "memory plugin", Tags: []string{"memory"}, Language: "Go", Stars: 100, Contributors: &contributors, PushedAt: stringPtr("2026-08-17T00:00:00Z"), Category: "memory", Score: &score, Grade: &grade, ScoredAt: &scoredAt, ScoreVersion: &scoreVersion, IsFeatured: true, Install: store.Install{Cmd: &cmd, Kind: &kind, Source: "manual", ProbedAt: &probedAt}, }}, i18n: map[string]map[string]store.I18nEntry{ - "owner/memory": {"en": {Highlights: []string{"Fast"}, UpdatedAt: "2026-08-17T00:00:00Z"}}, + "owner/repo~memory": {"en": {Highlights: []string{"Fast"}, UpdatedAt: "2026-08-17T00:00:00Z"}}, }, snapshots: map[string][]store.SnapshotRow{ - "owner/memory": { + "owner/repo~memory": { {Date: "2026-08-10", Stars: 90}, {Date: "2026-08-17", Stars: 95}, }, @@ -115,6 +118,7 @@ func TestGraphQLPluginMappingVariablesAndFragments(t *testing.T) { query PluginByName($name: ID!) { plugin(fullName: $name) { ...core + repositoryFullName packagePath ... on Plugin { language repositoryUrl rating { score grade calculatedAt version } } install { cmd kind source probedAt } i18n(locale: "en") { locale highlights updatedAt } @@ -123,17 +127,23 @@ func TestGraphQLPluginMappingVariablesAndFragments(t *testing.T) { } } fragment core on Plugin { id fullName stars contributors } - `, "PluginByName", map[string]any{"name": "owner/memory"}) + `, "PluginByName", map[string]any{"name": "owner/repo~memory"}) if err != nil { t.Fatalf("Execute() error = %v", err) } plugin := data["plugin"].(map[string]any) - if got := plugin["fullName"]; got != "owner/memory" { - t.Errorf("fullName = %v, want owner/memory", got) + if got := plugin["fullName"]; got != "owner/repo~memory" { + t.Errorf("fullName = %v, want owner/repo~memory", got) } - if got := plugin["id"]; got != "owner/memory" { - t.Errorf("id = %v, want owner/memory", got) + if got := plugin["id"]; got != "@owner/memory" { + t.Errorf("id = %v, want @owner/memory", got) + } + if got := plugin["repositoryFullName"]; got != repositoryFullName { + t.Errorf("repositoryFullName = %v, want %s", got, repositoryFullName) + } + if got := plugin["packagePath"]; got != packagePath { + t.Errorf("packagePath = %v, want %s", got, packagePath) } if got := plugin["contributors"]; got != 4 { t.Errorf("contributors = %v, want 4", got) @@ -147,7 +157,7 @@ func TestGraphQLPluginMappingVariablesAndFragments(t *testing.T) { if got := plugin["install"].(map[string]any)["kind"]; got != "npm" { t.Errorf("install.kind = %v, want npm", got) } - if got := plugin["repositoryUrl"]; got != "https://github.com/owner/memory" { + if got := plugin["repositoryUrl"]; got != "https://github.com/owner/repo" { t.Errorf("repositoryUrl = %v, want GitHub repository URL", got) } rating := plugin["rating"].(map[string]any) diff --git a/server/internal/httpapi/market.go b/server/internal/httpapi/market.go index dcfb3ca..a51d20d 100644 --- a/server/internal/httpapi/market.go +++ b/server/internal/httpapi/market.go @@ -177,7 +177,12 @@ func (s *Server) handleMarketPlugins(w http.ResponseWriter, r *http.Request) { // 满足 schema 的必填约束(如 id 不合法、repository/package 皆缺),整项跳过, // 保证响应一定过 schema 校验。 func buildMarketItem(p *store.Plugin) (marketItem, bool) { - id := p.FullName + id := p.Identity() + if p.RepositoryFullName != nil { + // The external schema requires an alphanumeric first byte, so scoped npm + // identities use its documented npm: transport encoding. + id = "npm:" + id + } if len(id) > 160 || !marketIDPattern.MatchString(id) { return marketItem{}, false } @@ -206,6 +211,9 @@ func buildMarketItem(p *store.Plugin) (marketItem, bool) { } if validMarketHTTPSURL(repoURL) { item.Repository = &marketRepository{URL: repoURL} + if p.PackagePath != nil && validMarketSubdirectory(*p.PackagePath) { + item.Repository.Subdirectory = *p.PackagePath + } } if p.Category != "" && len(p.Category) <= 64 && categoryIDPattern.MatchString(p.Category) { @@ -236,7 +244,7 @@ func buildMarketItem(p *store.Plugin) (marketItem, bool) { // monorepo 子包:随安装证据发 repository.subdirectory。 // v2.0.1/v2.0.2 桌面端的安装复核要求它与 npm manifest 的 // repository.directory 相等,v2.0.3 起忽略,发出即两代通吃。 - if item.Repository != nil && p.NpmRepoDirectory != nil && + if item.Repository != nil && item.Repository.Subdirectory == "" && p.NpmRepoDirectory != nil && validMarketSubdirectory(*p.NpmRepoDirectory) { item.Repository.Subdirectory = *p.NpmRepoDirectory } diff --git a/server/internal/httpapi/market_test.go b/server/internal/httpapi/market_test.go index a104483..aa62dad 100644 --- a/server/internal/httpapi/market_test.go +++ b/server/internal/httpapi/market_test.go @@ -24,7 +24,8 @@ type marketTestPage struct { DisplayName string `json:"displayName"` Summary string `json:"summary"` Repository *struct { - URL string `json:"url"` + URL string `json:"url"` + Subdirectory string `json:"subdirectory"` } `json:"repository"` Package *struct { Registry string `json:"registry"` @@ -49,6 +50,7 @@ func marketPlugin(i int) store.Plugin { confirmed := true return store.Plugin{ FullName: full, + ID: full, Name: fmt.Sprintf("repo%03d", i), Owner: fmt.Sprintf("owner%03d", i), URL: "https://github.com/" + full, @@ -190,6 +192,29 @@ func TestMarketItemRequiredFields(t *testing.T) { } } +func TestMarketWorkspaceItemUsesPackageIdentityAndSubdirectory(t *testing.T) { + p := marketPlugin(0) + catalogID := "@amaster.ai/dsh-a2a" + repositoryFullName := p.FullName + packagePath := "packages/dsh-a2a" + p.ID = catalogID + p.RepositoryFullName = &repositoryFullName + p.PackagePath = &packagePath + p.Name = catalogID + + body := marketPageAt(t, seededMarketServer(t, []store.Plugin{p}), "/market/v1/plugins") + if len(body.Items) != 1 { + t.Fatalf("len(items) = %d, want 1", len(body.Items)) + } + item := body.Items[0] + if item.ID != "npm:@amaster.ai/dsh-a2a" { + t.Fatalf("id = %q", item.ID) + } + if item.Repository == nil || item.Repository.Subdirectory != packagePath { + t.Fatalf("repository = %+v", item.Repository) + } +} + // 门控:npm_desktop_installable=1(探测判定过桌面端 npm preview 全部复核)才输出 // package/latestVersion;backlink 通过但 desktop_installable=0 的条目两者都不出现。 func TestMarketItemPackageGate(t *testing.T) { diff --git a/server/internal/httpapi/plugins_test.go b/server/internal/httpapi/plugins_test.go index 65a4bba..477d9bf 100644 --- a/server/internal/httpapi/plugins_test.go +++ b/server/internal/httpapi/plugins_test.go @@ -22,6 +22,7 @@ func seededPluginServer(count int) *Server { full := fmt.Sprintf("owner%03d/repo%03d", i, i) plugins[i] = store.Plugin{ FullName: full, + ID: full, Name: fmt.Sprintf("repo%03d", i), Owner: fmt.Sprintf("owner%03d", i), URL: "https://github.com/" + full, @@ -125,6 +126,7 @@ func seededPluginServerWithPluginMarks(count int, marks map[int]bool) *Server { full := fmt.Sprintf("owner%03d/repo%03d", i, i) plugins[i] = store.Plugin{ FullName: full, + ID: full, Name: fmt.Sprintf("repo%03d", i), Owner: fmt.Sprintf("owner%03d", i), URL: "https://github.com/" + full, diff --git a/server/internal/store/migrate.go b/server/internal/store/migrate.go index 46d53be..187f665 100644 --- a/server/internal/store/migrate.go +++ b/server/internal/store/migrate.go @@ -62,7 +62,10 @@ var migrations = []string{ entry_committed INTEGER, npm_latest_version TEXT, npm_repo_backlink INTEGER NOT NULL DEFAULT 0, - npm_desktop_installable INTEGER NOT NULL DEFAULT 0 + npm_desktop_installable INTEGER NOT NULL DEFAULT 0, + npm_repo_directory TEXT, + repository_full_name TEXT, + package_path TEXT )`, `CREATE TABLE IF NOT EXISTS plugin_i18n ( full_name TEXT NOT NULL, @@ -218,6 +221,9 @@ var pluginColumnMigrations = []pluginColumnMigration{ {"npm_latest_version", `ALTER TABLE plugins ADD COLUMN npm_latest_version TEXT`}, {"npm_repo_backlink", `ALTER TABLE plugins ADD COLUMN npm_repo_backlink INTEGER NOT NULL DEFAULT 0`}, {"npm_desktop_installable", `ALTER TABLE plugins ADD COLUMN npm_desktop_installable INTEGER NOT NULL DEFAULT 0`}, + {"npm_repo_directory", `ALTER TABLE plugins ADD COLUMN npm_repo_directory TEXT`}, + {"repository_full_name", `ALTER TABLE plugins ADD COLUMN repository_full_name TEXT`}, + {"package_path", `ALTER TABLE plugins ADD COLUMN package_path TEXT`}, } func (s *Store) Migrate(ctx context.Context) error { diff --git a/server/internal/store/migrate_test.go b/server/internal/store/migrate_test.go index 5326ae4..f9cc60c 100644 --- a/server/internal/store/migrate_test.go +++ b/server/internal/store/migrate_test.go @@ -41,6 +41,8 @@ func TestMigrateExecutesPluginReadContract(t *testing.T) { "ALTER TABLE plugins ADD COLUMN score_version", "ALTER TABLE plugins ADD COLUMN release_etag", "ALTER TABLE plugins ADD COLUMN entry_committed", + "ALTER TABLE plugins ADD COLUMN repository_full_name", + "ALTER TABLE plugins ADD COLUMN package_path", "CREATE TABLE IF NOT EXISTS forum_threads", "CREATE TABLE IF NOT EXISTS forum_posts", "CREATE TABLE IF NOT EXISTS plugin_votes", diff --git a/server/internal/store/plugins.go b/server/internal/store/plugins.go index ac8ca73..639e17d 100644 --- a/server/internal/store/plugins.go +++ b/server/internal/store/plugins.go @@ -19,8 +19,12 @@ var ( // 字段口径对齐 Next 端 src/lib/plugins-db.ts 与 plugins 表。 type Plugin struct { FullName string `json:"full_name"` - Name string `json:"name"` - Owner string `json:"owner"` + // ID 是公开目录身份:单包仓库沿用 full_name,workspace 子包使用精确包名。 + ID string `json:"id"` + RepositoryFullName *string `json:"repository_full_name,omitempty"` + PackagePath *string `json:"package_path,omitempty"` + Name string `json:"name"` + Owner string `json:"owner"` // URL 是旧 REST 字段;RepositoryURL 是语义明确的兼容新增字段,二者值相同。 URL string `json:"url"` RepositoryURL string `json:"repository_url"` @@ -54,11 +58,19 @@ type Plugin struct { // 仅内部使用(标准目录源端点判断是否可公开 npm 安装信息),不进公开 JSON。 NpmLatestVersion *string `json:"-"` NpmRepoBacklink bool `json:"-"` + NpmRepoDirectory *string `json:"-"` // NpmDesktopInstallable 是探测脚本对桌面端 npm preview 复核的综合结论 // (scripts/lib/install.mjs desktopPreviewVerdict):为真才发安装证据。 NpmDesktopInstallable bool `json:"-"` } +func (p Plugin) Identity() string { + if p.ID != "" { + return p.ID + } + return p.FullName +} + // Install 聚合安装信息。cmd 是生效命令:运营手工核对的 install_cmd 优先, // 否则用探测推导的 install_cmd_auto;source 标注取的是哪边。 type Install struct { @@ -111,14 +123,19 @@ type SnapshotRow struct { // 置顶用 is_featured * featured_boost 而不是 is_featured: 被运营降权的推荐项目 // (featured_boost=0)仍然带 is_featured 标记与徽标,只是不再插队到 star 之前。 const loadPluginsSQL = ` -SELECT full_name, name, owner, url, description, tags, language, +SELECT full_name, repository_full_name, package_path, + name, owner, url, description, tags, language, stars, contributors, pushed_at, archived, category, score, scored_at, score_version, is_featured, is_insider, is_official, is_risky, risk_note, first_seen_at, last_synced_at, install_cmd, install_kind, install_cmd_auto, pkg_name, pkg_version, npm_published, npm_latest_version, npm_repo_backlink, npm_desktop_installable, - release_tgz_url, release_tag, install_probed_at, is_plugin + npm_repo_directory, release_tgz_url, release_tag, install_probed_at, is_plugin FROM plugins WHERE is_present = 1 AND is_offtopic = 0 + AND (repository_full_name IS NOT NULL OR has_bundle = 1 OR NOT EXISTS ( + SELECT 1 FROM plugins child + WHERE child.repository_full_name = plugins.full_name AND child.is_present = 1 + )) ORDER BY is_risky ASC, is_featured * featured_boost DESC, stars DESC, full_name` func (s *Store) LoadAllPlugins(ctx context.Context) ([]Plugin, error) { @@ -132,6 +149,7 @@ func (s *Store) LoadAllPlugins(ctx context.Context) ([]Plugin, error) { for rows.Next() { var ( p Plugin + repositoryFullName, packagePath sql.NullString tags sql.NullString description, language, pushedAt sql.NullString category, firstSeen, lastSynced sql.NullString @@ -145,20 +163,28 @@ func (s *Store) LoadAllPlugins(ctx context.Context) ([]Plugin, error) { npmLatest sql.NullString npmBacklink sql.NullInt64 npmDesktopInstallable sql.NullInt64 + npmRepoDirectory sql.NullString tgzURL, relTag, probedAt sql.NullString npmPub sql.NullInt64 isPlugin sql.NullInt64 ) if err := rows.Scan( - &p.FullName, &p.Name, &p.Owner, &p.URL, &description, &tags, &language, + &p.FullName, &repositoryFullName, &packagePath, + &p.Name, &p.Owner, &p.URL, &description, &tags, &language, &p.Stars, &contributors, &pushedAt, &archived, &category, &score, &scoredAt, &scoreVersion, &featured, &insider, &offic, &risky, &riskNote, &firstSeen, &lastSynced, &installCmd, &installKind, &cmdAuto, &pkgName, &pkgVersion, - &npmPub, &npmLatest, &npmBacklink, &npmDesktopInstallable, + &npmPub, &npmLatest, &npmBacklink, &npmDesktopInstallable, &npmRepoDirectory, &tgzURL, &relTag, &probedAt, &isPlugin, ); err != nil { return nil, err } + if repositoryFullName.Valid && repositoryFullName.String != "" { + p.RepositoryFullName = &repositoryFullName.String + } + if packagePath.Valid && packagePath.String != "" { + p.PackagePath = &packagePath.String + } p.Description = description.String p.RepositoryURL = p.URL p.Language = language.String @@ -207,7 +233,14 @@ func (s *Store) LoadAllPlugins(ctx context.Context) ([]Plugin, error) { } p.NpmRepoBacklink = npmBacklink.Int64 != 0 p.NpmDesktopInstallable = npmDesktopInstallable.Int64 != 0 + if npmRepoDirectory.Valid && npmRepoDirectory.String != "" { + p.NpmRepoDirectory = &npmRepoDirectory.String + } p.Install = buildInstall(installCmd, cmdAuto, installKind, pkgName, pkgVersion, npmPub, tgzURL, relTag, probedAt, npmLatest, npmDesktopInstallable) + p.ID = p.FullName + if p.RepositoryFullName != nil && p.Install.PkgName != nil { + p.ID = *p.Install.PkgName + } out = append(out, p) } return out, rows.Err() diff --git a/src/app/[locale]/plugins/[owner]/[repo]/page.tsx b/src/app/[locale]/plugins/[owner]/[repo]/page.tsx index d8db58c..fa4c3b0 100644 --- a/src/app/[locale]/plugins/[owner]/[repo]/page.tsx +++ b/src/app/[locale]/plugins/[owner]/[repo]/page.tsx @@ -173,6 +173,9 @@ export default async function PluginDetailPage({ // 攒够最低档(100)才给作者「下载量炫耀小标」这个选项,免得复制出去一张空徽章。 const downloads = plugin.downloadSummary; const downloadsBadge = downloads != null && downloadTier(downloads.total) != null; + const sourceUrl = plugin.packagePath + ? `${plugin.url}/tree/HEAD/${plugin.packagePath}` + : plugin.url; const ai = plugin.scoreDetail?.ai; const parts = plugin.scoreDetail?.parts; @@ -211,9 +214,10 @@ export default async function PluginDetailPage({ const jsonLd = { "@context": "https://schema.org", "@type": "SoftwareSourceCode", + identifier: plugin.id ?? plugin.fullName, name: plugin.name, description: description || undefined, - codeRepository: plugin.url, + codeRepository: sourceUrl, programmingLanguage: plugin.language || undefined, keywords: ["DeepSeek Harness plugin", "dsh-plugin", ...plugin.tags].join(", "), author: { @@ -264,7 +268,7 @@ export default async function PluginDetailPage({