Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions docs/api-query.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:<package-name>` 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 |
Expand Down Expand Up @@ -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!
Expand Down
13 changes: 10 additions & 3 deletions docs/api-query.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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 翻译、快照与增长

Expand Down Expand Up @@ -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"`)。

| 参数 | 默认 / 范围 | 含义 |
Expand Down Expand Up @@ -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!
Expand Down
52 changes: 35 additions & 17 deletions scripts/collect-score-evidence.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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 历史 + 名下最佳其他原创仓
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
31 changes: 19 additions & 12 deletions scripts/extract-plugin-images.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import { createHash } from "node:crypto";

import { openDb } from "./lib/db.mjs";
import { pluginSource, repositoryPath } from "./lib/workspaces.mjs";

import {
customSocialPreview,
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -260,27 +262,32 @@ 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 的耐心。
//
// 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
AND (?1 = 1
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();
Expand All @@ -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,
Expand All @@ -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 };

Expand Down
Loading