From 28cd836f448f97ecc5ad26c65913718a4b2b388c Mon Sep 17 00:00:00 2001 From: huqi Date: Thu, 21 May 2026 21:36:39 +0800 Subject: [PATCH 1/3] feat(ohpm): add package search command --- clis/ohpm/search.js | 204 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 clis/ohpm/search.js diff --git a/clis/ohpm/search.js b/clis/ohpm/search.js new file mode 100644 index 000000000..e31dad8e1 --- /dev/null +++ b/clis/ohpm/search.js @@ -0,0 +1,204 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; + +const OHPM_HOME = 'https://ohpm.openharmony.cn/'; +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 50; + +function normalizeText(value) { + return String(value ?? '').replace(/\s+/g, ' ').trim(); +} + +function normalizeLimit(value) { + const limit = Number(value ?? DEFAULT_LIMIT); + + if (!Number.isInteger(limit) || limit <= 0) { + throw new ArgumentError( + 'limit must be a positive integer', + `Example: opencli ohpm search axios --limit ${DEFAULT_LIMIT}`, + ); + } + + if (limit > MAX_LIMIT) { + throw new ArgumentError( + `limit must be <= ${MAX_LIMIT}`, + `Example: opencli ohpm search axios --limit ${MAX_LIMIT}`, + ); + } + + return limit; +} + +function normalizePackage(raw, index) { + const item = raw?.package ?? raw; + const links = item?.links ?? raw?.links ?? {}; + const name = normalizeText(item?.name ?? raw?.name ?? raw?.packageName ?? raw?.pkgName); + + if (!name) return null; + + return { + rank: index + 1, + name, + version: normalizeText(item?.version ?? raw?.version ?? raw?.latestVersion), + description: normalizeText(item?.description ?? raw?.description ?? raw?.summary), + author: normalizeText( + item?.publisher?.username ?? + item?.publisher?.email ?? + item?.author?.name ?? + item?.author ?? + raw?.authorName ?? + raw?.author, + ), + url: normalizeText( + links?.npm ?? + item?.url ?? + raw?.url ?? + `https://ohpm.openharmony.cn/#/cn/detail/${encodeURIComponent(name)}`, + ), + }; +} + +function pickArray(payload) { + if (Array.isArray(payload)) return payload; + if (Array.isArray(payload?.objects)) return payload.objects; + if (Array.isArray(payload?.data)) return payload.data; + if (Array.isArray(payload?.data?.list)) return payload.data.list; + if (Array.isArray(payload?.data?.rows)) return payload.data.rows; + if (Array.isArray(payload?.result)) return payload.result; + if (Array.isArray(payload?.result?.list)) return payload.result.list; + if (Array.isArray(payload?.packages)) return payload.packages; + if (Array.isArray(payload?.list)) return payload.list; + if (Array.isArray(payload?.rows)) return payload.rows; + return []; +} + +function normalizePackages(payload, limit) { + return pickArray(payload) + .map(normalizePackage) + .filter(Boolean) + .slice(0, limit) + .map((item, index) => ({ ...item, rank: index + 1 })); +} + +function buildSearchScript(query, limit) { + return ` + (async () => { + const query = ${JSON.stringify(query)}; + const limit = ${JSON.stringify(limit)}; + const origin = location.origin; + const endpoints = [ + origin + '/ohpm/-/v1/search?text=' + encodeURIComponent(query) + '&size=' + limit, + origin + '/ohpm/v1/search?keyword=' + encodeURIComponent(query) + '&pageNo=1&pageSize=' + limit, + origin + '/api/package/search?keyword=' + encodeURIComponent(query) + '&pageNo=1&pageSize=' + limit, + origin + '/api/packages?keyword=' + encodeURIComponent(query) + '&pageNo=1&pageSize=' + limit, + ]; + + for (const url of endpoints) { + try { + const response = await fetch(url, { + headers: { accept: 'application/json, text/plain, */*' }, + }); + const contentType = response.headers.get('content-type') || ''; + if (!response.ok || !contentType.includes('json')) continue; + const json = await response.json(); + return { source: url, payload: json }; + } catch { + // Try the next known endpoint shape. + } + } + + const input = Array.from(document.querySelectorAll('input')).find((el) => { + const text = [el.placeholder, el.name, el.id, el.className].join(' ').toLowerCase(); + return text.includes('search') || text.includes('搜索') || text.includes('keyword'); + }); + + if (input) { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setter?.call(input, query); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true })); + input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + const anchors = Array.from(document.querySelectorAll('a[href]')); + const rows = []; + const seen = new Set(); + + const text = (el) => (el?.innerText || el?.textContent || '').replace(/\\s+/g, ' ').trim(); + + for (const anchor of anchors) { + const href = anchor.getAttribute('href') || ''; + const label = text(anchor); + const url = new URL(href, location.href).href; + const nearby = anchor.closest('li, .card, .el-card, [class*=card], [class*=item], [class*=package]') || anchor.parentElement; + const body = text(nearby); + const haystack = (label + ' ' + body + ' ' + href).toLowerCase(); + + if (!label || seen.has(label)) continue; + if (query && !haystack.includes(query.toLowerCase())) continue; + if (!href.includes('detail') && !href.includes('package') && !label.startsWith('@')) continue; + + seen.add(label); + rows.push({ + name: label, + version: '', + description: body.replace(label, '').trim().slice(0, 240), + author: '', + url, + }); + } + + return { source: 'dom', payload: { rows } }; + })() + `; +} + +cli({ + site: 'ohpm', + name: 'search', + access: 'read', + description: 'Search OpenHarmony OHPM third-party packages', + domain: 'ohpm.openharmony.cn', + strategy: Strategy.PUBLIC, + browser: true, + args: [ + { name: 'query', positional: true, required: true, help: 'Package keyword' }, + { name: 'limit', type: 'int', default: DEFAULT_LIMIT, help: `Number of packages, max ${MAX_LIMIT}` }, + ], + columns: ['rank', 'name', 'version', 'description', 'author', 'url'], + + func: async (page, args) => { + const query = normalizeText(args.query); + const limit = normalizeLimit(args.limit); + + if (!query) { + throw new ArgumentError( + 'query is required', + 'Example: opencli ohpm search axios --limit 10', + ); + } + + await page.goto(OHPM_HOME); + + let result; + try { + result = await page.evaluate(buildSearchScript(query, limit)); + } catch (error) { + throw new CommandExecutionError( + `Failed to search OHPM packages: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + const rows = normalizePackages(result?.payload, limit); + + if (rows.length === 0) { + throw new EmptyResultError( + 'ohpm search', + `No OHPM packages matched "${query}". The site may have changed its API or rendered markup.`, + ); + } + + return rows; + }, +}); From 9990ca003ce388effc23f468006102fdff595c76 Mon Sep 17 00:00:00 2001 From: hu-qi Date: Thu, 21 May 2026 22:18:39 +0800 Subject: [PATCH 2/3] Add OHPM registry adapters --- cli-manifest.json | 157 +++++++++++++++++++++++++ clis/ohpm/dependents.js | 51 ++++++++ clis/ohpm/keywords.js | 27 +++++ clis/ohpm/ohpm.test.js | 151 ++++++++++++++++++++++++ clis/ohpm/package.js | 86 ++++++++++++++ clis/ohpm/search.js | 249 ++++++++++------------------------------ clis/ohpm/utils.js | 108 +++++++++++++++++ 7 files changed, 638 insertions(+), 191 deletions(-) create mode 100644 clis/ohpm/dependents.js create mode 100644 clis/ohpm/keywords.js create mode 100644 clis/ohpm/ohpm.test.js create mode 100644 clis/ohpm/package.js create mode 100644 clis/ohpm/utils.js diff --git a/cli-manifest.json b/cli-manifest.json index aa08cd26c..eb9817d0c 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -18547,6 +18547,163 @@ "modulePath": "oeis/sequence.js", "sourceFile": "oeis/sequence.js" }, + { + "site": "ohpm", + "name": "dependents", + "description": "List packages that depend on an OHPM package", + "access": "read", + "domain": "ohpm.openharmony.cn", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "name", + "type": "str", + "required": true, + "positional": true, + "help": "OHPM package name (e.g. \"@ohos/axios\")" + }, + { + "name": "version", + "type": "string", + "required": false, + "help": "Package version; omit for latest" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Max dependents (1-50)" + } + ], + "columns": [ + "rank", + "name", + "version", + "dependent", + "url" + ], + "type": "js", + "modulePath": "ohpm/dependents.js", + "sourceFile": "ohpm/dependents.js" + }, + { + "site": "ohpm", + "name": "keywords", + "description": "List hot OHPM search keywords", + "access": "read", + "domain": "ohpm.openharmony.cn", + "strategy": "public", + "browser": false, + "args": [], + "columns": [ + "rank", + "keyword" + ], + "type": "js", + "modulePath": "ohpm/keywords.js", + "sourceFile": "ohpm/keywords.js" + }, + { + "site": "ohpm", + "name": "package", + "description": "Single OHPM package metadata (version, downloads, license, repository)", + "access": "read", + "domain": "ohpm.openharmony.cn", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "name", + "type": "str", + "required": true, + "positional": true, + "help": "OHPM package name (e.g. \"@ohos/axios\")" + }, + { + "name": "version", + "type": "string", + "required": false, + "help": "Package version; omit for latest" + } + ], + "columns": [ + "name", + "version", + "description", + "license", + "downloads", + "likes", + "points", + "popularity", + "fileSize", + "fileCount", + "repository", + "keywords", + "publisher", + "org", + "dependencies", + "devDependencies", + "dependents", + "versions", + "published", + "url" + ], + "type": "js", + "modulePath": "ohpm/package.js", + "sourceFile": "ohpm/package.js" + }, + { + "site": "ohpm", + "name": "search", + "description": "Search OpenHarmony OHPM third-party packages by keyword", + "access": "read", + "domain": "ohpm.openharmony.cn", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Package keyword (e.g. \"axios\", \"json\")" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Max results (1-50)" + }, + { + "name": "sort", + "type": "string", + "default": "relevancy", + "required": false, + "help": "Sort: relevancy, likes, latest" + } + ], + "columns": [ + "rank", + "name", + "latestVersion", + "description", + "license", + "keywords", + "likes", + "points", + "popularity", + "publisher", + "org", + "published", + "url" + ], + "type": "js", + "modulePath": "ohpm/search.js", + "sourceFile": "ohpm/search.js" + }, { "site": "ones", "name": "login", diff --git a/clis/ohpm/dependents.js b/clis/ohpm/dependents.js new file mode 100644 index 000000000..7a0c7d7e0 --- /dev/null +++ b/clis/ohpm/dependents.js @@ -0,0 +1,51 @@ +// ohpm dependents — list packages that depend on an OHPM package. +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { EmptyResultError } from '@jackwener/opencli/errors'; +import { + OHPM_API, + normalizeText, + ohpmFetch, + packageUrl, + requireBoundedInt, + requirePackageName, +} from './utils.js'; + +cli({ + site: 'ohpm', + name: 'dependents', + access: 'read', + description: 'List packages that depend on an OHPM package', + domain: 'ohpm.openharmony.cn', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'name', positional: true, required: true, help: 'OHPM package name (e.g. "@ohos/axios")' }, + { name: 'version', type: 'string', required: false, help: 'Package version; omit for latest' }, + { name: 'limit', type: 'int', default: 20, help: 'Max dependents (1-50)' }, + ], + columns: ['rank', 'name', 'version', 'dependent', 'url'], + func: async (args) => { + const name = requirePackageName(args.name); + const version = normalizeText(args.version); + const limit = requireBoundedInt(args.limit, 20, 50); + const path = version + ? `${encodeURIComponent(name)}/${encodeURIComponent(version)}` + : encodeURIComponent(name); + const body = await ohpmFetch(`${OHPM_API}/v1/detail/${path}`, `ohpm dependents ${name}`); + const item = body?.body; + if (!item?.name) { + throw new EmptyResultError('ohpm dependents', `OHPM returned no metadata for "${name}".`); + } + const rows = Array.isArray(item.dependent?.rows) ? item.dependent.rows : []; + if (!rows.length) { + throw new EmptyResultError('ohpm dependents', `OHPM returned no dependents for "${name}".`); + } + return rows.slice(0, limit).map((dependent, i) => ({ + rank: i + 1, + name: normalizeText(item.name), + version: normalizeText(item.version), + dependent: normalizeText(dependent), + url: packageUrl(item.name), + })); + }, +}); diff --git a/clis/ohpm/keywords.js b/clis/ohpm/keywords.js new file mode 100644 index 000000000..45897fa01 --- /dev/null +++ b/clis/ohpm/keywords.js @@ -0,0 +1,27 @@ +// ohpm keywords — current hot search terms shown on the OHPM home page. +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { EmptyResultError } from '@jackwener/opencli/errors'; +import { OHPM_API, normalizeText, ohpmFetch } from './utils.js'; + +cli({ + site: 'ohpm', + name: 'keywords', + access: 'read', + description: 'List hot OHPM search keywords', + domain: 'ohpm.openharmony.cn', + strategy: Strategy.PUBLIC, + browser: false, + args: [], + columns: ['rank', 'keyword'], + func: async () => { + const body = await ohpmFetch(`${OHPM_API}/v1/frequency`, 'ohpm keywords'); + const list = Array.isArray(body?.body) ? body.body : []; + if (!list.length) { + throw new EmptyResultError('ohpm keywords', 'OHPM returned no hot keywords.'); + } + return list.map((keyword, i) => ({ + rank: i + 1, + keyword: normalizeText(keyword), + })); + }, +}); diff --git a/clis/ohpm/ohpm.test.js b/clis/ohpm/ohpm.test.js new file mode 100644 index 000000000..3e3f61f58 --- /dev/null +++ b/clis/ohpm/ohpm.test.js @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { requirePackageName, requireSort } from './utils.js'; +import './search.js'; +import './package.js'; +import './dependents.js'; +import './keywords.js'; + +function jsonResponse(body, init = {}) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + ...init, + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('ohpm shared argument validation', () => { + it('accepts scoped and unscoped package names but rejects malformed values', () => { + expect(requirePackageName('@ohos/axios')).toBe('@ohos/axios'); + expect(requirePackageName('mobileukey')).toBe('mobileukey'); + expect(() => requirePackageName('')).toThrow(ArgumentError); + expect(() => requirePackageName('@bad')).toThrow(ArgumentError); + expect(() => requirePackageName('bad space')).toThrow(ArgumentError); + }); + + it('normalizes supported sort aliases and rejects unsupported API sort keys', () => { + expect(requireSort(undefined)).toBe('relevancy'); + expect(requireSort('popular')).toBe('likes'); + expect(requireSort('newest')).toBe('latest'); + expect(() => requireSort('download')).toThrow(ArgumentError); + }); +}); + +describe('ohpm search adapter', () => { + const cmd = getRegistry().get('ohpm/search'); + + it('maps OHPM search rows into stable output columns', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ + code: 200, + body: { + rows: [{ + name: '@ohos/axios', + latestVersion: '2.2.10', + description: 'HTTP client', + license: 'MIT', + keywords: ['request', 'http'], + likes: 328, + points: 25, + popularity: 8631, + publisherName: 'SettZhao', + org: 'ohos', + latestPublishTime: 1779348839684, + }], + }, + }))); + + const rows = await cmd.func({ query: 'axios', limit: 1, sort: 'popular' }); + + expect(rows).toEqual([{ + rank: 1, + name: '@ohos/axios', + latestVersion: '2.2.10', + description: 'HTTP client', + license: 'MIT', + keywords: 'request, http', + likes: 328, + points: 25, + popularity: 8631, + publisher: 'SettZhao', + org: 'ohos', + published: '2026-05-21', + url: 'https://ohpm.openharmony.cn/#/cn/detail/%40ohos%2Faxios', + }]); + }); + + it('maps API errors to CommandExecutionError and empty rows to EmptyResultError', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(jsonResponse({ code: 217008, message: 'Invalid sortedType!' }, { status: 400 }))); + await expect(cmd.func({ query: 'axios' })).rejects.toThrow(CommandExecutionError); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(jsonResponse({ code: 200, body: { rows: [] } }))); + await expect(cmd.func({ query: 'no-such-package' })).rejects.toThrow(EmptyResultError); + }); +}); + +describe('ohpm package adapter', () => { + const cmd = getRegistry().get('ohpm/package'); + + it('fills missing detail description from search metadata for latest package lookup', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse({ + code: 200, + body: { + name: '@ohos/axios', + version: '2.2.10', + description: '', + license: 'MIT', + downloads: 183168, + keywords: ['request'], + publishTime: 1779348839684, + dependent: { total: 55 }, + versions: { '2.2.10': 1779348839684 }, + }, + })) + .mockResolvedValueOnce(jsonResponse({ + code: 200, + body: { + rows: [{ name: '@ohos/axios', description: 'Axios for OpenHarmony' }], + }, + })); + vi.stubGlobal('fetch', fetchMock); + + const rows = await cmd.func({ name: '@ohos/axios' }); + + expect(rows[0].description).toBe('Axios for OpenHarmony'); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +describe('ohpm dependents and keywords adapters', () => { + it('returns one row per dependent with a caller supplied limit', async () => { + const cmd = getRegistry().get('ohpm/dependents'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ + code: 200, + body: { + name: '@ohos/axios', + version: '2.2.10', + dependent: { rows: ['a', 'b', 'c'] }, + }, + }))); + + const rows = await cmd.func({ name: '@ohos/axios', limit: 2 }); + + expect(rows.map((row) => row.dependent)).toEqual(['a', 'b']); + }); + + it('maps hot keywords to ranked rows', async () => { + const cmd = getRegistry().get('ohpm/keywords'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ code: 200, body: ['axios', 'json'] }))); + + await expect(cmd.func({})).resolves.toEqual([ + { rank: 1, keyword: 'axios' }, + { rank: 2, keyword: 'json' }, + ]); + }); +}); diff --git a/clis/ohpm/package.js b/clis/ohpm/package.js new file mode 100644 index 000000000..4503b4ea5 --- /dev/null +++ b/clis/ohpm/package.js @@ -0,0 +1,86 @@ +// ohpm package — fetch a single OpenHarmony OHPM package's metadata. +// +// Hits `oh-package/openapi/v1/detail//`. The latest version is +// returned when --version is omitted. +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { EmptyResultError } from '@jackwener/opencli/errors'; +import { + OHPM_API, + dateFromMs, + normalizeText, + ohpmFetch, + packageUrl, + requirePackageName, +} from './utils.js'; + +function versionCount(versions) { + return versions && typeof versions === 'object' ? Object.keys(versions).length : 0; +} + +async function findLatestDescription(name) { + const params = new URLSearchParams({ + condition: name, + pageNum: '1', + pageSize: '10', + sortedType: 'relevancy', + isHomePage: 'false', + }); + const body = await ohpmFetch(`${OHPM_API}/v1/search?${params}`, `ohpm package ${name} search metadata`); + const rows = Array.isArray(body?.body?.rows) ? body.body.rows : []; + const exact = rows.find((row) => normalizeText(row.name) === name); + return normalizeText(exact?.description); +} + +cli({ + site: 'ohpm', + name: 'package', + access: 'read', + description: 'Single OHPM package metadata (version, downloads, license, repository)', + domain: 'ohpm.openharmony.cn', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'name', positional: true, required: true, help: 'OHPM package name (e.g. "@ohos/axios")' }, + { name: 'version', type: 'string', required: false, help: 'Package version; omit for latest' }, + ], + columns: [ + 'name', 'version', 'description', 'license', 'downloads', 'likes', 'points', + 'popularity', 'fileSize', 'fileCount', 'repository', 'keywords', 'publisher', + 'org', 'dependencies', 'devDependencies', 'dependents', 'versions', 'published', 'url', + ], + func: async (args) => { + const name = requirePackageName(args.name); + const version = normalizeText(args.version); + const path = version + ? `${encodeURIComponent(name)}/${encodeURIComponent(version)}` + : encodeURIComponent(name); + const body = await ohpmFetch(`${OHPM_API}/v1/detail/${path}`, `ohpm package ${name}`); + const item = body?.body; + if (!item?.name) { + throw new EmptyResultError('ohpm package', `OHPM returned no metadata for "${name}".`); + } + const description = normalizeText(item.description) || (!version ? await findLatestDescription(name) : ''); + return [{ + name: normalizeText(item.name), + version: normalizeText(item.version), + description, + license: normalizeText(item.license), + downloads: item.downloads != null ? Number(item.downloads) : null, + likes: item.likes != null ? Number(item.likes) : null, + points: item.points != null ? Number(item.points) : null, + popularity: item.popularity != null ? Number(item.popularity) : null, + fileSize: item.fileSize != null ? Number(item.fileSize) : null, + fileCount: item.fileNums != null ? Number(item.fileNums) : null, + repository: normalizeText(item.repository), + keywords: Array.isArray(item.keywords) ? item.keywords.join(', ') : '', + publisher: normalizeText(item.publisherName || item.authorName), + org: normalizeText(item.org), + dependencies: item.dependencies?.total != null ? Number(item.dependencies.total) : null, + devDependencies: item.devDependencies?.total != null ? Number(item.devDependencies.total) : null, + dependents: item.dependent?.total != null ? Number(item.dependent.total) : null, + versions: versionCount(item.versions), + published: dateFromMs(item.publishTime), + url: packageUrl(item.name), + }]; + }, +}); diff --git a/clis/ohpm/search.js b/clis/ohpm/search.js index e31dad8e1..9c3f5297b 100644 --- a/clis/ohpm/search.js +++ b/clis/ohpm/search.js @@ -1,204 +1,71 @@ +// ohpm search — search the OpenHarmony OHPM third-party package registry. +// +// Hits the public `oh-package/openapi/v1/search` endpoint used by +// https://ohpm.openharmony.cn/ and returns package rows that round-trip into +// `ohpm package`. import { cli, Strategy } from '@jackwener/opencli/registry'; -import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; - -const OHPM_HOME = 'https://ohpm.openharmony.cn/'; -const DEFAULT_LIMIT = 20; -const MAX_LIMIT = 50; - -function normalizeText(value) { - return String(value ?? '').replace(/\s+/g, ' ').trim(); -} - -function normalizeLimit(value) { - const limit = Number(value ?? DEFAULT_LIMIT); - - if (!Number.isInteger(limit) || limit <= 0) { - throw new ArgumentError( - 'limit must be a positive integer', - `Example: opencli ohpm search axios --limit ${DEFAULT_LIMIT}`, - ); - } - - if (limit > MAX_LIMIT) { - throw new ArgumentError( - `limit must be <= ${MAX_LIMIT}`, - `Example: opencli ohpm search axios --limit ${MAX_LIMIT}`, - ); - } - - return limit; -} - -function normalizePackage(raw, index) { - const item = raw?.package ?? raw; - const links = item?.links ?? raw?.links ?? {}; - const name = normalizeText(item?.name ?? raw?.name ?? raw?.packageName ?? raw?.pkgName); - - if (!name) return null; - - return { - rank: index + 1, - name, - version: normalizeText(item?.version ?? raw?.version ?? raw?.latestVersion), - description: normalizeText(item?.description ?? raw?.description ?? raw?.summary), - author: normalizeText( - item?.publisher?.username ?? - item?.publisher?.email ?? - item?.author?.name ?? - item?.author ?? - raw?.authorName ?? - raw?.author, - ), - url: normalizeText( - links?.npm ?? - item?.url ?? - raw?.url ?? - `https://ohpm.openharmony.cn/#/cn/detail/${encodeURIComponent(name)}`, - ), - }; -} - -function pickArray(payload) { - if (Array.isArray(payload)) return payload; - if (Array.isArray(payload?.objects)) return payload.objects; - if (Array.isArray(payload?.data)) return payload.data; - if (Array.isArray(payload?.data?.list)) return payload.data.list; - if (Array.isArray(payload?.data?.rows)) return payload.data.rows; - if (Array.isArray(payload?.result)) return payload.result; - if (Array.isArray(payload?.result?.list)) return payload.result.list; - if (Array.isArray(payload?.packages)) return payload.packages; - if (Array.isArray(payload?.list)) return payload.list; - if (Array.isArray(payload?.rows)) return payload.rows; - return []; -} - -function normalizePackages(payload, limit) { - return pickArray(payload) - .map(normalizePackage) - .filter(Boolean) - .slice(0, limit) - .map((item, index) => ({ ...item, rank: index + 1 })); -} - -function buildSearchScript(query, limit) { - return ` - (async () => { - const query = ${JSON.stringify(query)}; - const limit = ${JSON.stringify(limit)}; - const origin = location.origin; - const endpoints = [ - origin + '/ohpm/-/v1/search?text=' + encodeURIComponent(query) + '&size=' + limit, - origin + '/ohpm/v1/search?keyword=' + encodeURIComponent(query) + '&pageNo=1&pageSize=' + limit, - origin + '/api/package/search?keyword=' + encodeURIComponent(query) + '&pageNo=1&pageSize=' + limit, - origin + '/api/packages?keyword=' + encodeURIComponent(query) + '&pageNo=1&pageSize=' + limit, - ]; - - for (const url of endpoints) { - try { - const response = await fetch(url, { - headers: { accept: 'application/json, text/plain, */*' }, - }); - const contentType = response.headers.get('content-type') || ''; - if (!response.ok || !contentType.includes('json')) continue; - const json = await response.json(); - return { source: url, payload: json }; - } catch { - // Try the next known endpoint shape. - } - } - - const input = Array.from(document.querySelectorAll('input')).find((el) => { - const text = [el.placeholder, el.name, el.id, el.className].join(' ').toLowerCase(); - return text.includes('search') || text.includes('搜索') || text.includes('keyword'); - }); - - if (input) { - const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; - setter?.call(input, query); - input.dispatchEvent(new Event('input', { bubbles: true })); - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true })); - input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', bubbles: true })); - await new Promise((resolve) => setTimeout(resolve, 2000)); - } - - const anchors = Array.from(document.querySelectorAll('a[href]')); - const rows = []; - const seen = new Set(); - - const text = (el) => (el?.innerText || el?.textContent || '').replace(/\\s+/g, ' ').trim(); - - for (const anchor of anchors) { - const href = anchor.getAttribute('href') || ''; - const label = text(anchor); - const url = new URL(href, location.href).href; - const nearby = anchor.closest('li, .card, .el-card, [class*=card], [class*=item], [class*=package]') || anchor.parentElement; - const body = text(nearby); - const haystack = (label + ' ' + body + ' ' + href).toLowerCase(); - - if (!label || seen.has(label)) continue; - if (query && !haystack.includes(query.toLowerCase())) continue; - if (!href.includes('detail') && !href.includes('package') && !label.startsWith('@')) continue; - - seen.add(label); - rows.push({ - name: label, - version: '', - description: body.replace(label, '').trim().slice(0, 240), - author: '', - url, - }); - } - - return { source: 'dom', payload: { rows } }; - })() - `; -} +import { EmptyResultError } from '@jackwener/opencli/errors'; +import { + OHPM_API, + dateFromMs, + normalizeText, + ohpmFetch, + packageUrl, + requireBoundedInt, + requireSort, + requireString, +} from './utils.js'; cli({ site: 'ohpm', name: 'search', access: 'read', - description: 'Search OpenHarmony OHPM third-party packages', + description: 'Search OpenHarmony OHPM third-party packages by keyword', domain: 'ohpm.openharmony.cn', strategy: Strategy.PUBLIC, - browser: true, + browser: false, args: [ - { name: 'query', positional: true, required: true, help: 'Package keyword' }, - { name: 'limit', type: 'int', default: DEFAULT_LIMIT, help: `Number of packages, max ${MAX_LIMIT}` }, + { name: 'query', positional: true, required: true, help: 'Package keyword (e.g. "axios", "json")' }, + { name: 'limit', type: 'int', default: 20, help: 'Max results (1-50)' }, + { name: 'sort', type: 'string', default: 'relevancy', help: 'Sort: relevancy, likes, latest' }, ], - columns: ['rank', 'name', 'version', 'description', 'author', 'url'], - - func: async (page, args) => { - const query = normalizeText(args.query); - const limit = normalizeLimit(args.limit); - - if (!query) { - throw new ArgumentError( - 'query is required', - 'Example: opencli ohpm search axios --limit 10', - ); - } - - await page.goto(OHPM_HOME); - - let result; - try { - result = await page.evaluate(buildSearchScript(query, limit)); - } catch (error) { - throw new CommandExecutionError( - `Failed to search OHPM packages: ${error instanceof Error ? error.message : String(error)}`, - ); - } - - const rows = normalizePackages(result?.payload, limit); - - if (rows.length === 0) { - throw new EmptyResultError( - 'ohpm search', - `No OHPM packages matched "${query}". The site may have changed its API or rendered markup.`, - ); + columns: [ + 'rank', 'name', 'latestVersion', 'description', 'license', 'keywords', + 'likes', 'points', 'popularity', 'publisher', 'org', 'published', 'url', + ], + func: async (args) => { + const query = requireString(args.query, 'query'); + const limit = requireBoundedInt(args.limit, 20, 50); + const sort = requireSort(args.sort); + const params = new URLSearchParams({ + condition: query, + pageNum: '1', + pageSize: String(limit), + sortedType: sort, + isHomePage: 'false', + }); + const body = await ohpmFetch(`${OHPM_API}/v1/search?${params}`, 'ohpm search'); + const rows = Array.isArray(body?.body?.rows) ? body.body.rows : []; + if (!rows.length) { + throw new EmptyResultError('ohpm search', `No OHPM packages matched "${query}".`); } - - return rows; + return rows.slice(0, limit).map((item, i) => { + const name = normalizeText(item.name); + return { + rank: i + 1, + name, + latestVersion: normalizeText(item.latestVersion), + description: normalizeText(item.description), + license: normalizeText(item.license), + keywords: Array.isArray(item.keywords) ? item.keywords.join(', ') : '', + likes: item.likes != null ? Number(item.likes) : null, + points: item.points != null ? Number(item.points) : null, + popularity: item.popularity != null ? Number(item.popularity) : null, + publisher: normalizeText(item.publisherName || item.authorName), + org: normalizeText(item.org), + published: dateFromMs(item.latestPublishTime), + url: name ? packageUrl(name) : '', + }; + }); }, }); diff --git a/clis/ohpm/utils.js b/clis/ohpm/utils.js new file mode 100644 index 000000000..4a3d59646 --- /dev/null +++ b/clis/ohpm/utils.js @@ -0,0 +1,108 @@ +import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; + +export const OHPM_BASE = 'https://ohpm.openharmony.cn'; +export const OHPM_API = `${OHPM_BASE}/ohpmweb/registry/oh-package/openapi`; +const UA = 'opencli-ohpm-adapter (+https://github.com/jackwener/opencli)'; +const PACKAGE_NAME = /^(?:@[A-Za-z0-9][A-Za-z0-9._-]*\/)?[A-Za-z0-9][A-Za-z0-9._-]*$/; + +export function normalizeText(value) { + return String(value ?? '').replace(/\s+/g, ' ').trim(); +} + +export function dateFromMs(value) { + const ms = Number(value); + if (!Number.isFinite(ms) || ms <= 0) return ''; + const date = new Date(ms); + return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10); +} + +export function requireString(value, label) { + const text = normalizeText(value); + if (!text) throw new ArgumentError(`ohpm ${label} cannot be empty`); + return text; +} + +export function requirePackageName(value) { + const name = normalizeText(value); + if (!name) throw new ArgumentError('ohpm package name is required (e.g. "@ohos/axios")'); + if (name.length > 214) { + throw new ArgumentError(`ohpm package name "${value}" is too long (max 214 chars)`); + } + if (!PACKAGE_NAME.test(name)) { + throw new ArgumentError( + `ohpm package name "${value}" is not a valid package name`, + 'Names are 1-214 chars of letters / digits / "-._" (scoped form: "@scope/name").', + ); + } + return name; +} + +export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') { + const raw = value ?? defaultValue; + const n = typeof raw === 'number' ? raw : Number(raw); + if (!Number.isInteger(n) || n <= 0) { + throw new ArgumentError(`ohpm ${label} must be a positive integer`); + } + if (n > maxValue) { + throw new ArgumentError(`ohpm ${label} must be <= ${maxValue}`); + } + return n; +} + +export function requireSort(value) { + const sort = normalizeText(value || 'relevancy'); + const aliases = { + relevance: 'relevancy', + relevant: 'relevancy', + popular: 'likes', + popularity: 'likes', + like: 'likes', + newest: 'latest', + recent: 'latest', + }; + const normalized = aliases[sort] || sort; + if (!['relevancy', 'likes', 'latest'].includes(normalized)) { + throw new ArgumentError( + `ohpm sort must be one of relevancy, likes, latest; got "${value}"`, + 'The OHPM public API currently rejects other sort keys.', + ); + } + return normalized; +} + +export function packageUrl(name) { + return `${OHPM_BASE}/#/cn/detail/${encodeURIComponent(name)}`; +} + +export async function ohpmFetch(url, label) { + let resp; + try { + resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } }); + } + catch (err) { + throw new CommandExecutionError( + `${label} request failed: ${err?.message ?? err}`, + 'Check that ohpm.openharmony.cn is reachable from this network.', + ); + } + if (resp.status === 404) { + throw new EmptyResultError(label, `OHPM returned 404 for ${url}.`); + } + if (resp.status === 429) { + throw new CommandExecutionError( + `${label} returned HTTP 429 (rate limited)`, + 'OHPM throttles bursts; wait a few seconds and retry.', + ); + } + let body; + try { + body = await resp.json(); + } + catch (err) { + throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`); + } + if (!resp.ok || body?.code && body.code !== 200) { + throw new CommandExecutionError(`${label} returned HTTP ${resp.status}: ${body?.message ?? body?.code ?? 'unknown error'}`); + } + return body; +} From 07fcb5109a5a3e4bacd6a4c5bd8e717de118018b Mon Sep 17 00:00:00 2001 From: hu-qi Date: Thu, 21 May 2026 22:25:26 +0800 Subject: [PATCH 3/3] Document OHPM adapter --- docs/.vitepress/config.mts | 1 + docs/adapters/browser/ohpm.md | 81 +++++++++++++++++++++++++++++++++++ docs/adapters/index.md | 1 + 3 files changed, 83 insertions(+) create mode 100644 docs/adapters/browser/ohpm.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index ab6a8bc13..c0b2ed3ca 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -141,6 +141,7 @@ export default defineConfig({ { text: 'LessWrong', link: '/adapters/browser/lesswrong' }, { text: 'Lobsters', link: '/adapters/browser/lobsters' }, { text: 'Steam', link: '/adapters/browser/steam' }, + { text: 'OHPM', link: '/adapters/browser/ohpm' }, ], }, { diff --git a/docs/adapters/browser/ohpm.md b/docs/adapters/browser/ohpm.md new file mode 100644 index 000000000..0fb56fd1f --- /dev/null +++ b/docs/adapters/browser/ohpm.md @@ -0,0 +1,81 @@ +# OHPM + +**Mode**: 🌐 Public · **Domain**: `ohpm.openharmony.cn` + +Search and inspect OpenHarmony/HarmonyOS third-party packages from the public OHPM registry. The adapter uses the same unauthenticated JSON endpoints as `https://ohpm.openharmony.cn/`, so no browser session or login is required. + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli ohpm search ` | Search OpenHarmony OHPM third-party packages by keyword | +| `opencli ohpm package ` | Single OHPM package metadata (version, downloads, license, repository) | +| `opencli ohpm dependents ` | List packages that depend on an OHPM package | +| `opencli ohpm keywords` | List hot OHPM search keywords | + +## Usage Examples + +```bash +# Search packages +opencli ohpm search axios --limit 10 +opencli ohpm search json --sort latest + +# Inspect a single package (use `name` from search rows) +opencli ohpm package @ohos/axios +opencli ohpm package @ohos/axios --version 2.2.10 + +# Reverse dependencies +opencli ohpm dependents @ohos/axios --limit 20 + +# Home-page hot keywords +opencli ohpm keywords + +# JSON output +opencli ohpm package @ohos/axios -f json +``` + +## Output Columns + +| Command | Columns | +|---------|---------| +| `search` | `rank, name, latestVersion, description, license, keywords, likes, points, popularity, publisher, org, published, url` | +| `package` | `name, version, description, license, downloads, likes, points, popularity, fileSize, fileCount, repository, keywords, publisher, org, dependencies, devDependencies, dependents, versions, published, url` | +| `dependents` | `rank, name, version, dependent, url` | +| `keywords` | `rank, keyword` | + +The `name` column from `search` round-trips into `package` and `dependents`. + +## Options + +### `search` + +| Option | Description | +|--------|-------------| +| `query` (positional) | Free-text search query | +| `--limit` | Max results (1-50, default: 20) | +| `--sort` | One of `relevancy`, `likes`, `latest` (default: `relevancy`). Aliases: `popular` → `likes`, `newest` → `latest`. | + +### `package` + +| Option | Description | +|--------|-------------| +| `name` (positional) | OHPM package name (e.g. `@ohos/axios`) | +| `--version` | Package version; omit for latest | + +### `dependents` + +| Option | Description | +|--------|-------------| +| `name` (positional) | OHPM package name | +| `--version` | Package version; omit for latest | +| `--limit` | Max dependents (1-50, default: 20) | + +## Caveats + +- The OHPM public API currently accepts `sortedType` values `relevancy`, `likes`, and `latest`. Unsupported values such as `download` are rejected by the server and surfaced as typed command errors. +- Some package detail responses omit `description`; latest-package lookups fill it from the exact package row in search metadata when available. +- `dependents` returns the first page exposed by the package detail endpoint. + +## Prerequisites + +- No browser required — uses public OHPM registry endpoints. diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 948470f72..00224d76a 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -119,6 +119,7 @@ Run `opencli list` for the live registry. | **[steam](./browser/steam.md)** | `top-sellers` | 🌐 Public | | **[coingecko](./browser/coingecko.md)** | `top` `coin` `trending` `exchanges` `categories` `derivatives` `global` | 🌐 Public | | **[npm](./browser/npm.md)** | `search` `package` `downloads` | 🌐 Public | +| **[ohpm](./browser/ohpm.md)** | `search` `package` `dependents` `keywords` | 🌐 Public | | **[pypi](./browser/pypi.md)** | `package` `downloads` | 🌐 Public | | **[crates](./browser/crates.md)** | `search` `crate` | 🌐 Public | | **[mdn](./browser/mdn.md)** | `search` | 🌐 Public |