mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-21 14:03:35 +08:00
feat: normalize skill market sources
This commit is contained in:
parent
9d08ca4e43
commit
5aeab5c206
@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from 'bun:test'
|
import { describe, expect, it } from 'bun:test'
|
||||||
|
import { normalizeClawHubList, normalizeClawHubScan } from '../services/skillMarket/clawhubAdapter.js'
|
||||||
|
import { normalizeSkillHubDetail, normalizeSkillHubList } from '../services/skillMarket/skillhubAdapter.js'
|
||||||
import {
|
import {
|
||||||
|
CLAWHUB_SCAN_RESPONSE,
|
||||||
CLAWHUB_TOP_SKILLS_RESPONSE,
|
CLAWHUB_TOP_SKILLS_RESPONSE,
|
||||||
|
SKILLHUB_DETAIL_RESPONSE,
|
||||||
SKILLHUB_TOP_SKILLS_RESPONSE,
|
SKILLHUB_TOP_SKILLS_RESPONSE,
|
||||||
} from './fixtures/skill-market.js'
|
} from './fixtures/skill-market.js'
|
||||||
|
|
||||||
@ -21,3 +25,55 @@ describe('skill market fixtures', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('skill market source normalization', () => {
|
||||||
|
it('normalizes ClawHub catalog items as primary clean candidates', () => {
|
||||||
|
const result = normalizeClawHubList(CLAWHUB_TOP_SKILLS_RESPONSE)
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(1)
|
||||||
|
expect(result.items[0]).toMatchObject({
|
||||||
|
source: 'clawhub',
|
||||||
|
sourceMode: 'primary',
|
||||||
|
slug: 'skill-vetter',
|
||||||
|
displayName: 'Skill Vetter',
|
||||||
|
canonicalUrl: 'https://clawhub.ai/skill-vetter',
|
||||||
|
trustState: 'clean',
|
||||||
|
installed: false,
|
||||||
|
requiresApiKey: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes ClawHub scan responses into trust metadata', () => {
|
||||||
|
expect(normalizeClawHubScan(CLAWHUB_SCAN_RESPONSE)).toEqual({
|
||||||
|
trustState: 'clean',
|
||||||
|
trustSummary: 'No dangerous patterns detected.',
|
||||||
|
packageSha256: 'a'.repeat(64),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes SkillHub list items as fallback candidates with Chinese summary', () => {
|
||||||
|
const result = normalizeSkillHubList(SKILLHUB_TOP_SKILLS_RESPONSE)
|
||||||
|
|
||||||
|
expect(result.items[0]).toMatchObject({
|
||||||
|
source: 'skillhub',
|
||||||
|
sourceMode: 'fallback',
|
||||||
|
slug: 'skill-vetter',
|
||||||
|
summaryZh: 'AI智能体技能安全预审工具。',
|
||||||
|
canonicalUrl: 'https://clawhub.ai/spclaudehome/skill-vetter',
|
||||||
|
trustState: 'benign',
|
||||||
|
requiresApiKey: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes SkillHub detail security reports', () => {
|
||||||
|
const detail = normalizeSkillHubDetail(SKILLHUB_DETAIL_RESPONSE)
|
||||||
|
|
||||||
|
expect(detail).toMatchObject({
|
||||||
|
source: 'skillhub',
|
||||||
|
sourceMode: 'fallback',
|
||||||
|
slug: 'skill-vetter',
|
||||||
|
trustState: 'benign',
|
||||||
|
trustSummary: '安全,无风险',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
70
src/server/services/skillMarket/clawhubAdapter.ts
Normal file
70
src/server/services/skillMarket/clawhubAdapter.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import type { SkillMarketItem, SkillMarketListResult, SkillMarketTrustState } from './types.js'
|
||||||
|
|
||||||
|
type ClawHubListResponse = {
|
||||||
|
items?: Array<{
|
||||||
|
slug?: string
|
||||||
|
displayName?: string
|
||||||
|
summary?: string
|
||||||
|
description?: string | null
|
||||||
|
topics?: string[]
|
||||||
|
tags?: { latest?: string } | null
|
||||||
|
stats?: { downloads?: number; installs?: number; stars?: number }
|
||||||
|
latestVersion?: { version?: string; license?: string | null } | null
|
||||||
|
}>
|
||||||
|
nextCursor?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClawHubScanResponse = {
|
||||||
|
status?: string
|
||||||
|
hasWarnings?: boolean
|
||||||
|
sha256?: string
|
||||||
|
scanners?: Record<string, { status?: string; summary?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeClawHubList(payload: ClawHubListResponse): SkillMarketListResult {
|
||||||
|
const items = (payload.items ?? [])
|
||||||
|
.filter((item) => item.slug && item.displayName)
|
||||||
|
.map((item): SkillMarketItem => ({
|
||||||
|
source: 'clawhub',
|
||||||
|
sourceMode: 'primary',
|
||||||
|
slug: item.slug!,
|
||||||
|
displayName: item.displayName!,
|
||||||
|
summary: item.summary || item.description || '',
|
||||||
|
owner: undefined,
|
||||||
|
canonicalUrl: `https://clawhub.ai/${item.slug}`,
|
||||||
|
license: item.latestVersion?.license ?? null,
|
||||||
|
version: item.latestVersion?.version ?? item.tags?.latest,
|
||||||
|
downloads: item.stats?.downloads,
|
||||||
|
installs: item.stats?.installs,
|
||||||
|
stars: item.stats?.stars,
|
||||||
|
tags: item.topics,
|
||||||
|
requiresApiKey: false,
|
||||||
|
trustState: 'clean',
|
||||||
|
installed: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
nextCursor: payload.nextCursor ?? null,
|
||||||
|
source: 'clawhub',
|
||||||
|
sourceStatus: 'ok',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeClawHubScan(payload: ClawHubScanResponse): {
|
||||||
|
trustState: SkillMarketTrustState
|
||||||
|
trustSummary?: string
|
||||||
|
packageSha256?: string
|
||||||
|
} {
|
||||||
|
const scannerSummary = Object.values(payload.scanners ?? {}).find((entry) => entry.summary)?.summary
|
||||||
|
if (payload.status === 'clean' && !payload.hasWarnings) {
|
||||||
|
return { trustState: 'clean', trustSummary: scannerSummary, packageSha256: payload.sha256 }
|
||||||
|
}
|
||||||
|
if (payload.status === 'suspicious' || payload.hasWarnings) {
|
||||||
|
return { trustState: 'warning', trustSummary: scannerSummary, packageSha256: payload.sha256 }
|
||||||
|
}
|
||||||
|
if (payload.status === 'malicious' || payload.status === 'blocked') {
|
||||||
|
return { trustState: 'blocked', trustSummary: scannerSummary, packageSha256: payload.sha256 }
|
||||||
|
}
|
||||||
|
return { trustState: 'unknown', trustSummary: scannerSummary, packageSha256: payload.sha256 }
|
||||||
|
}
|
||||||
118
src/server/services/skillMarket/skillhubAdapter.ts
Normal file
118
src/server/services/skillMarket/skillhubAdapter.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import type { SkillMarketDetail, SkillMarketItem, SkillMarketListResult, SkillMarketTrustState } from './types.js'
|
||||||
|
|
||||||
|
type SkillHubListResponse = {
|
||||||
|
code?: number
|
||||||
|
data?: {
|
||||||
|
skills?: Array<{
|
||||||
|
slug?: string
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
description_zh?: string
|
||||||
|
downloads?: number
|
||||||
|
installs?: number
|
||||||
|
stars?: number
|
||||||
|
ownerName?: string
|
||||||
|
upstream_url?: string
|
||||||
|
version?: string
|
||||||
|
labels?: { requires_api_key?: string }
|
||||||
|
category?: string
|
||||||
|
verified?: boolean
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SkillHubDetailResponse = {
|
||||||
|
latestVersion?: { version?: string }
|
||||||
|
owner?: { handle?: string; displayName?: string }
|
||||||
|
securityReports?: Record<string, { status?: string; statusText?: string }>
|
||||||
|
skill?: {
|
||||||
|
slug?: string
|
||||||
|
displayName?: string
|
||||||
|
summary?: string
|
||||||
|
summary_zh?: string
|
||||||
|
sourceUrl?: string
|
||||||
|
stats?: { downloads?: number; installs?: number; stars?: number }
|
||||||
|
labels?: { requires_api_key?: string }
|
||||||
|
category?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiresApiKey(labels?: { requires_api_key?: string }) {
|
||||||
|
return labels?.requires_api_key === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
function trustFromReports(reports?: Record<string, { status?: string; statusText?: string }>): {
|
||||||
|
trustState: SkillMarketTrustState
|
||||||
|
trustSummary?: string
|
||||||
|
} {
|
||||||
|
const values = Object.values(reports ?? {})
|
||||||
|
if (values.some((report) => report.status === 'malicious' || report.status === 'blocked')) {
|
||||||
|
return { trustState: 'blocked', trustSummary: values.find((report) => report.statusText)?.statusText }
|
||||||
|
}
|
||||||
|
if (values.length > 0 && values.every((report) => report.status === 'benign')) {
|
||||||
|
return { trustState: 'benign', trustSummary: values.find((report) => report.statusText)?.statusText }
|
||||||
|
}
|
||||||
|
return { trustState: 'unknown', trustSummary: values.find((report) => report.statusText)?.statusText }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSkillHubList(payload: SkillHubListResponse): SkillMarketListResult {
|
||||||
|
const items = (payload.data?.skills ?? [])
|
||||||
|
.filter((item) => item.slug && item.name)
|
||||||
|
.map((item): SkillMarketItem => ({
|
||||||
|
source: 'skillhub',
|
||||||
|
sourceMode: 'fallback',
|
||||||
|
slug: item.slug!,
|
||||||
|
displayName: item.name!,
|
||||||
|
summary: item.description || item.description_zh || '',
|
||||||
|
summaryZh: item.description_zh,
|
||||||
|
owner: item.ownerName,
|
||||||
|
canonicalUrl: item.upstream_url || `https://skillhub.cn/skills/${item.slug}`,
|
||||||
|
upstreamUrl: item.upstream_url,
|
||||||
|
version: item.version,
|
||||||
|
downloads: item.downloads,
|
||||||
|
installs: item.installs,
|
||||||
|
stars: item.stars,
|
||||||
|
category: item.category,
|
||||||
|
requiresApiKey: requiresApiKey(item.labels),
|
||||||
|
trustState: item.verified ? 'signed' : 'benign',
|
||||||
|
installed: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
nextCursor: null,
|
||||||
|
source: 'skillhub',
|
||||||
|
sourceStatus: 'fallback',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSkillHubDetail(payload: SkillHubDetailResponse): SkillMarketDetail {
|
||||||
|
const trust = trustFromReports(payload.securityReports)
|
||||||
|
const skill = payload.skill ?? {}
|
||||||
|
const slug = skill.slug || 'unknown'
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'skillhub',
|
||||||
|
sourceMode: 'fallback',
|
||||||
|
slug,
|
||||||
|
displayName: skill.displayName || slug,
|
||||||
|
summary: skill.summary || skill.summary_zh || '',
|
||||||
|
summaryZh: skill.summary_zh,
|
||||||
|
owner: payload.owner?.handle || payload.owner?.displayName,
|
||||||
|
canonicalUrl: skill.sourceUrl || `https://skillhub.cn/skills/${slug}`,
|
||||||
|
version: payload.latestVersion?.version,
|
||||||
|
downloads: skill.stats?.downloads,
|
||||||
|
installs: skill.stats?.installs,
|
||||||
|
stars: skill.stats?.stars,
|
||||||
|
category: skill.category,
|
||||||
|
requiresApiKey: requiresApiKey(skill.labels),
|
||||||
|
trustState: trust.trustState,
|
||||||
|
trustSummary: trust.trustSummary,
|
||||||
|
installed: false,
|
||||||
|
files: [],
|
||||||
|
riskLabels: requiresApiKey(skill.labels) ? ['requires-api-key'] : [],
|
||||||
|
installEligibility: trust.trustState === 'blocked'
|
||||||
|
? { status: 'blocked', reason: 'SkillHub security report blocked this skill.' }
|
||||||
|
: { status: 'installable' },
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user