From b2a0a7a9612322497806563032b9e659c424bde5 Mon Sep 17 00:00:00 2001 From: jackwener Date: Wed, 8 Apr 2026 21:16:06 +0800 Subject: [PATCH 1/5] refactor: remove version field from GenerateOutcome and EarlyHint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All consumers are in the same repo and evolve together — version field adds ceremony without practical value at this stage. Keeps schema_version in VerifiedArtifactMetadata (sidecar file format). --- src/cli.test.ts | 2 +- src/generate-verified.test.ts | 7 +----- src/generate-verified.ts | 45 +++++++---------------------------- src/skill-generate.test.ts | 8 ------- 4 files changed, 11 insertions(+), 51 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 29d865f1..e1393985 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -62,7 +62,7 @@ describe('built-in browser commands verbose wiring', () => { mockExploreUrl.mockReset().mockResolvedValue({ ok: true }); mockRenderExploreSummary.mockReset().mockReturnValue('explore-summary'); - mockGenerateVerifiedFromUrl.mockReset().mockResolvedValue({ version: 1, status: 'success' }); + mockGenerateVerifiedFromUrl.mockReset().mockResolvedValue({ status: 'success' }); mockRenderGenerateVerifiedSummary.mockReset().mockReturnValue('generate-summary'); mockRecordSession.mockReset().mockResolvedValue({ candidateCount: 1 }); mockRenderRecordSummary.mockReset().mockReturnValue('record-summary'); diff --git a/src/generate-verified.test.ts b/src/generate-verified.test.ts index 557b4472..1704a503 100644 --- a/src/generate-verified.test.ts +++ b/src/generate-verified.test.ts @@ -113,7 +113,6 @@ describe('generateVerifiedFromUrl', () => { noRegister: true, }); - expect(result.version).toBe(1); expect(result.status).toBe('blocked'); expect(result.reason).toBe('no-viable-api-surface'); expect(result.stage).toBe('explore'); @@ -313,7 +312,6 @@ describe('generateVerifiedFromUrl', () => { expect(mockExecutePipeline).toHaveBeenCalledTimes(1); expect(mockRegisterCommand).toHaveBeenCalledTimes(1); - expect(result.version).toBe(1); expect(result.status).toBe('success'); expect(result.adapter).toBeDefined(); expect(result.adapter!.command).toBe('demo/search'); @@ -503,7 +501,6 @@ describe('generateVerifiedFromUrl', () => { expect(mockExecutePipeline.mock.calls[1]?.[1]).toEqual(expect.arrayContaining([{ select: 'data.items' }])); // Verify structured escalation contract - expect(result.version).toBe(1); expect(result.status).toBe('needs-human-check'); expect(result.escalation).toBeDefined(); expect(result.escalation!.stage).toBe('fallback'); @@ -592,7 +589,7 @@ describe('generateVerifiedFromUrl', () => { // ── Contract shape validation ───────────────────────────────────────────── - it('all outcome statuses include version, status, and stats', async () => { + it('all outcome statuses include status and stats', async () => { // Test the blocked path - simplest to set up mockExploreUrl.mockResolvedValue({ site: 'demo', @@ -627,7 +624,6 @@ describe('generateVerifiedFromUrl', () => { }); // Every outcome must have these three fields - expect(result).toHaveProperty('version', 1); expect(result).toHaveProperty('status'); expect(result).toHaveProperty('stats'); expect(['success', 'blocked', 'needs-human-check']).toContain(result.status); @@ -680,7 +676,6 @@ describe('generateVerifiedFromUrl', () => { expect(hints).toHaveLength(1); expect(hints[0]).toEqual({ - version: 1, stage: 'explore', continue: false, reason: 'no-viable-api-surface', diff --git a/src/generate-verified.ts b/src/generate-verified.ts index 1e933b4f..a8992989 100644 --- a/src/generate-verified.ts +++ b/src/generate-verified.ts @@ -89,7 +89,6 @@ export type EarlyHintReason = | 'no-viable-candidate'; export interface EarlyHint { - version: 1; stage: 'explore' | 'synthesize' | 'cascade'; continue: boolean; reason: EarlyHintReason; @@ -104,7 +103,6 @@ export interface EarlyHint { } export type EarlyHintHandler = (hint: EarlyHint) => void; -export const GENERATE_OUTCOME_VERSION = 1 as const; // ── Outcome Types ───────────────────────────────────────────────────────────── @@ -143,7 +141,6 @@ export interface EscalationContext { } export type GenerateOutcome = { - version: typeof GENERATE_OUTCOME_VERSION; status: 'success' | 'blocked' | 'needs-human-check'; // success path @@ -511,7 +508,6 @@ function classifySessionError( ): GenerateOutcome { if (error instanceof BrowserConnectError) { return { - version: GENERATE_OUTCOME_VERSION, status: 'blocked', reason: 'execution-environment-unavailable', stage: 'verify', @@ -522,7 +518,6 @@ function classifySessionError( } if (error instanceof AuthRequiredError) { return { - version: GENERATE_OUTCOME_VERSION, status: 'blocked', reason: 'auth-too-complex', stage: 'verify', @@ -532,7 +527,6 @@ function classifySessionError( }; } return { - version: GENERATE_OUTCOME_VERSION, status: 'needs-human-check', escalation: buildEscalation('verify', 'verify-inconclusive', summary, site, { reusability: 'unverified-candidate', @@ -570,14 +564,12 @@ export async function generateVerifiedFromUrl(opts: GenerateVerifiedOptions): Pr // ── Early hint: explore result ────────────────────────────────────────── if (exploreResult.api_endpoint_count === 0) { opts.onEarlyHint?.({ - version: 1, stage: 'explore', continue: false, reason: 'no-viable-api-surface', confidence: 'high', }); return { - version: GENERATE_OUTCOME_VERSION, status: 'blocked', reason: 'no-viable-api-surface', stage: 'explore', @@ -587,7 +579,6 @@ export async function generateVerifiedFromUrl(opts: GenerateVerifiedOptions): Pr }; } opts.onEarlyHint?.({ - version: 1, stage: 'explore', continue: true, reason: 'api-surface-looks-viable', @@ -597,14 +588,12 @@ export async function generateVerifiedFromUrl(opts: GenerateVerifiedOptions): Pr // ── Early hint: synthesize result ─────────────────────────────────────── if (!selected || synthesizeResult.candidate_count === 0) { opts.onEarlyHint?.({ - version: 1, stage: 'synthesize', continue: false, reason: 'no-viable-candidate', confidence: 'high', }); return { - version: GENERATE_OUTCOME_VERSION, status: 'blocked', reason: 'no-viable-candidate', stage: 'synthesize', @@ -621,14 +610,12 @@ export async function generateVerifiedFromUrl(opts: GenerateVerifiedOptions): Pr if (!context.endpoint) { opts.onEarlyHint?.({ - version: 1, stage: 'synthesize', continue: false, reason: 'no-viable-candidate', confidence: 'medium', }); return { - version: GENERATE_OUTCOME_VERSION, status: 'blocked', reason: 'no-viable-candidate', stage: 'synthesize', @@ -647,7 +634,6 @@ export async function generateVerifiedFromUrl(opts: GenerateVerifiedOptions): Pr // No P2 hint is emitted — this is a P1-only decision per design guardrail. if (unsupportedArgs.length > 0) { return { - version: GENERATE_OUTCOME_VERSION, status: 'needs-human-check', escalation: buildEscalation('synthesize', 'unsupported-required-args', selected, bundle.manifest.site, { reusability: 'unverified-candidate', @@ -660,7 +646,6 @@ export async function generateVerifiedFromUrl(opts: GenerateVerifiedOptions): Pr } opts.onEarlyHint?.({ - version: 1, stage: 'synthesize', continue: true, reason: 'candidate-ready-for-verify', @@ -682,15 +667,13 @@ export async function generateVerifiedFromUrl(opts: GenerateVerifiedOptions): Pr const bestStrategy = await probeCandidateStrategy(page, context.endpoint!.url); if (!bestStrategy) { opts.onEarlyHint?.({ - version: 1, stage: 'cascade', continue: false, reason: 'auth-too-complex', confidence: 'high', }); return { - version: GENERATE_OUTCOME_VERSION, - status: 'blocked', + status: 'blocked', reason: 'auth-too-complex' as StopReason, stage: 'cascade' as Stage, confidence: 'high' as Confidence, @@ -700,7 +683,6 @@ export async function generateVerifiedFromUrl(opts: GenerateVerifiedOptions): Pr } opts.onEarlyHint?.({ - version: 1, stage: 'cascade', continue: true, reason: 'candidate-ready-for-verify', @@ -733,8 +715,7 @@ export async function generateVerifiedFromUrl(opts: GenerateVerifiedOptions): Pr ? await writeVerifiedArtifact(candidate, exploreResult.out_dir, buildMetadata()) : await registerVerifiedAdapter(candidate, buildMetadata()); return { - version: GENERATE_OUTCOME_VERSION, - status: 'success' as const, + status: 'success' as const, adapter: { site: candidate.site, name: candidate.name, @@ -760,8 +741,7 @@ export async function generateVerifiedFromUrl(opts: GenerateVerifiedOptions): Pr if ('terminal' in firstAttempt) { if (firstAttempt.terminal === 'blocked') { return { - version: GENERATE_OUTCOME_VERSION, - status: 'blocked', + status: 'blocked', reason: firstAttempt.reason ?? 'execution-environment-unavailable', stage: 'verify' as Stage, confidence: 'high' as Confidence, @@ -770,8 +750,7 @@ export async function generateVerifiedFromUrl(opts: GenerateVerifiedOptions): Pr }; } return { - version: GENERATE_OUTCOME_VERSION, - status: 'needs-human-check', + status: 'needs-human-check', escalation: buildEscalation( 'verify', firstAttempt.escalationReason ?? 'verify-inconclusive', @@ -793,8 +772,7 @@ export async function generateVerifiedFromUrl(opts: GenerateVerifiedOptions): Pr if (!repaired) { const escalationReason = mapVerifyFailureToEscalation(firstAttempt.reason); return { - version: GENERATE_OUTCOME_VERSION, - status: 'needs-human-check', + status: 'needs-human-check', escalation: buildEscalation('verify', escalationReason, selected, bundle.manifest.site, { reusability: 'unverified-candidate', confidence: 'medium', @@ -826,8 +804,7 @@ export async function generateVerifiedFromUrl(opts: GenerateVerifiedOptions): Pr ? await writeVerifiedArtifact(repaired, exploreResult.out_dir, buildMetadata()) : await registerVerifiedAdapter(repaired, buildMetadata()); return { - version: GENERATE_OUTCOME_VERSION, - status: 'success' as const, + status: 'success' as const, adapter: { site: repaired.site, name: repaired.name, @@ -845,8 +822,7 @@ export async function generateVerifiedFromUrl(opts: GenerateVerifiedOptions): Pr if ('terminal' in secondAttempt) { if (secondAttempt.terminal === 'blocked') { return { - version: GENERATE_OUTCOME_VERSION, - status: 'blocked', + status: 'blocked', reason: secondAttempt.reason ?? 'execution-environment-unavailable', stage: 'fallback' as Stage, confidence: 'high' as Confidence, @@ -855,8 +831,7 @@ export async function generateVerifiedFromUrl(opts: GenerateVerifiedOptions): Pr }; } return { - version: GENERATE_OUTCOME_VERSION, - status: 'needs-human-check', + status: 'needs-human-check', escalation: buildEscalation( 'fallback', secondAttempt.escalationReason ?? 'verify-inconclusive', @@ -873,8 +848,7 @@ export async function generateVerifiedFromUrl(opts: GenerateVerifiedOptions): Pr // ── Repair exhausted ──────────────────────────────────────────────── const escalationReason = mapVerifyFailureToEscalation(secondAttempt.reason); return { - version: GENERATE_OUTCOME_VERSION, - status: 'needs-human-check', + status: 'needs-human-check', escalation: buildEscalation('fallback', escalationReason, selected, bundle.manifest.site, { reusability: 'unverified-candidate', confidence: 'low', @@ -894,7 +868,6 @@ export async function generateVerifiedFromUrl(opts: GenerateVerifiedOptions): Pr export function renderGenerateVerifiedSummary(result: GenerateOutcome): string { const lines = [ `opencli generate: ${result.status.toUpperCase()}`, - `Schema version: ${result.version}`, ]; if (result.status === 'success' && result.adapter) { diff --git a/src/skill-generate.test.ts b/src/skill-generate.test.ts index 99180ade..77e83821 100644 --- a/src/skill-generate.test.ts +++ b/src/skill-generate.test.ts @@ -15,7 +15,6 @@ describe('mapOutcomeToSkillOutput', () => { it('maps success outcome correctly', () => { const outcome: GenerateOutcome = { - version: 1, status: 'success', adapter: { site: 'demo', @@ -44,7 +43,6 @@ describe('mapOutcomeToSkillOutput', () => { it('maps blocked outcome with no-viable-api-surface', () => { const outcome: GenerateOutcome = { - version: 1, status: 'blocked', reason: 'no-viable-api-surface', stage: 'explore', @@ -64,7 +62,6 @@ describe('mapOutcomeToSkillOutput', () => { it('maps blocked outcome with auth-too-complex', () => { const outcome: GenerateOutcome = { - version: 1, status: 'blocked', reason: 'auth-too-complex', stage: 'cascade', @@ -81,7 +78,6 @@ describe('mapOutcomeToSkillOutput', () => { it('maps blocked outcome with execution-environment-unavailable', () => { const outcome: GenerateOutcome = { - version: 1, status: 'blocked', reason: 'execution-environment-unavailable', stage: 'verify', @@ -98,7 +94,6 @@ describe('mapOutcomeToSkillOutput', () => { it('maps needs-human-check with unsupported-required-args', () => { const outcome: GenerateOutcome = { - version: 1, status: 'needs-human-check', escalation: { stage: 'synthesize', @@ -129,7 +124,6 @@ describe('mapOutcomeToSkillOutput', () => { it('maps needs-human-check with empty-result (inspect-with-browser)', () => { const outcome: GenerateOutcome = { - version: 1, status: 'needs-human-check', escalation: { stage: 'fallback', @@ -158,7 +152,6 @@ describe('mapOutcomeToSkillOutput', () => { it('maps needs-human-check with verify-inconclusive and path', () => { const outcome: GenerateOutcome = { - version: 1, status: 'needs-human-check', escalation: { stage: 'verify', @@ -187,7 +180,6 @@ describe('mapOutcomeToSkillOutput', () => { it('output satisfies SkillOutput contract shape', () => { const outcome: GenerateOutcome = { - version: 1, status: 'blocked', reason: 'no-viable-candidate', stage: 'synthesize', From 7626f0bfc5faa974d81560dd1048c2b1b3d1b044 Mon Sep 17 00:00:00 2001 From: jackwener Date: Wed, 8 Apr 2026 22:02:24 +0800 Subject: [PATCH 2/5] refactor: migrate all 123 CLI adapters from YAML to TypeScript Remove YAML as an adapter format entirely. All adapters now use TypeScript with cli() from @jackwener/opencli/registry. - Convert 123 YAML adapter files to TypeScript via batch script - Remove YAML scanning from discovery.ts (registerYamlCli, yaml import) - Remove scanYaml() and shouldReplaceManifestEntry() from build-manifest.ts - Change synthesize.ts to output JSON candidates (internal format) - Change generate-verified.ts to write .ts adapter files instead of .yaml - Delete yaml-schema.ts (dead code) and scripts/yaml-to-ts.mjs (one-time tool) - Update all tests to match new format Closes discussion in #OpenCLI thread 47ddba82. --- clis/bilibili/hot.ts | 36 ++++++++ clis/bilibili/hot.yaml | 38 -------- clis/bluesky/feeds.ts | 28 ++++++ clis/bluesky/feeds.yaml | 29 ------ clis/bluesky/followers.ts | 28 ++++++ clis/bluesky/followers.yaml | 33 ------- clis/bluesky/following.ts | 28 ++++++ clis/bluesky/following.yaml | 33 ------- clis/bluesky/profile.ts | 30 +++++++ clis/bluesky/profile.yaml | 27 ------ clis/bluesky/search.ts | 29 ++++++ clis/bluesky/search.yaml | 34 ------- clis/bluesky/starter-packs.ts | 29 ++++++ clis/bluesky/starter-packs.yaml | 34 ------- clis/bluesky/thread.ts | 31 +++++++ clis/bluesky/thread.yaml | 32 ------- clis/bluesky/trending.ts | 20 +++++ clis/bluesky/trending.yaml | 27 ------ clis/bluesky/user.ts | 34 +++++++ clis/bluesky/user.yaml | 34 ------- clis/devto/tag.ts | 33 +++++++ clis/devto/tag.yaml | 34 ------- clis/devto/top.ts | 27 ++++++ clis/devto/top.yaml | 29 ------ clis/devto/user.ts | 32 +++++++ clis/devto/user.yaml | 33 ------- clis/dictionary/examples.ts | 28 ++++++ clis/dictionary/examples.yaml | 25 ------ clis/dictionary/search.ts | 30 +++++++ clis/dictionary/search.yaml | 27 ------ clis/dictionary/synonyms.ts | 28 ++++++ clis/dictionary/synonyms.yaml | 25 ------ clis/douban/subject.ts | 119 ++++++++++++++++++++++++ clis/douban/subject.yaml | 107 ---------------------- clis/douban/top250.ts | 68 ++++++++++++++ clis/douban/top250.yaml | 70 --------------- clis/facebook/add-friend.ts | 44 +++++++++ clis/facebook/add-friend.yaml | 43 --------- clis/facebook/events.ts | 41 +++++++++ clis/facebook/events.yaml | 44 --------- clis/facebook/feed.ts | 60 +++++++++++++ clis/facebook/feed.yaml | 63 ------------- clis/facebook/friends.ts | 39 ++++++++ clis/facebook/friends.yaml | 42 --------- clis/facebook/groups.ts | 47 ++++++++++ clis/facebook/groups.yaml | 50 ----------- clis/facebook/join-group.ts | 45 ++++++++++ clis/facebook/join-group.yaml | 44 --------- clis/facebook/memories.ts | 36 ++++++++ clis/facebook/memories.yaml | 39 -------- clis/facebook/notifications.ts | 37 ++++++++ clis/facebook/notifications.yaml | 40 --------- clis/facebook/profile.ts | 38 ++++++++ clis/facebook/profile.yaml | 37 -------- clis/facebook/search.test.ts | 18 ++-- clis/facebook/search.ts | 39 ++++++++ clis/facebook/search.yaml | 47 ---------- clis/hackernews/ask.ts | 30 +++++++ clis/hackernews/ask.yaml | 38 -------- clis/hackernews/best.ts | 30 +++++++ clis/hackernews/best.yaml | 38 -------- clis/hackernews/jobs.ts | 28 ++++++ clis/hackernews/jobs.yaml | 36 -------- clis/hackernews/new.ts | 30 +++++++ clis/hackernews/new.yaml | 38 -------- clis/hackernews/search.ts | 37 ++++++++ clis/hackernews/search.yaml | 44 --------- clis/hackernews/show.ts | 30 +++++++ clis/hackernews/show.yaml | 38 -------- clis/hackernews/top.ts | 30 +++++++ clis/hackernews/top.yaml | 38 -------- clis/hackernews/user.ts | 23 +++++ clis/hackernews/user.yaml | 25 ------ clis/hupu/hot.ts | 41 +++++++++ clis/hupu/hot.yaml | 43 --------- clis/instagram/comment.ts | 48 ++++++++++ clis/instagram/comment.yaml | 52 ----------- clis/instagram/explore.ts | 42 +++++++++ clis/instagram/explore.yaml | 43 --------- clis/instagram/follow.ts | 44 +++++++++ clis/instagram/follow.yaml | 41 --------- clis/instagram/followers.ts | 46 ++++++++++ clis/instagram/followers.yaml | 51 ----------- clis/instagram/following.ts | 46 ++++++++++ clis/instagram/following.yaml | 51 ----------- clis/instagram/like.ts | 46 ++++++++++ clis/instagram/like.yaml | 46 ---------- clis/instagram/profile.ts | 40 +++++++++ clis/instagram/profile.yaml | 42 --------- clis/instagram/save.ts | 46 ++++++++++ clis/instagram/save.yaml | 46 ---------- clis/instagram/saved.ts | 39 ++++++++ clis/instagram/saved.yaml | 40 --------- clis/instagram/search.ts | 39 ++++++++ clis/instagram/search.yaml | 44 --------- clis/instagram/unfollow.ts | 41 +++++++++ clis/instagram/unfollow.yaml | 38 -------- clis/instagram/unlike.ts | 46 ++++++++++ clis/instagram/unlike.yaml | 46 ---------- clis/instagram/unsave.ts | 46 ++++++++++ clis/instagram/unsave.yaml | 46 ---------- clis/instagram/user.ts | 49 ++++++++++ clis/instagram/user.yaml | 54 ----------- clis/jike/post.ts | 62 +++++++++++++ clis/jike/post.yaml | 59 ------------ clis/jike/topic.ts | 52 +++++++++++ clis/jike/topic.yaml | 53 ----------- clis/jike/user.ts | 51 +++++++++++ clis/jike/user.yaml | 52 ----------- clis/jimeng/generate.ts | 84 +++++++++++++++++ clis/jimeng/generate.yaml | 85 ------------------ clis/jimeng/history.ts | 48 ++++++++++ clis/jimeng/history.yaml | 46 ---------- clis/linux-do/categories.ts | 66 ++++++++++++++ clis/linux-do/categories.yaml | 70 --------------- clis/linux-do/search.ts | 42 +++++++++ clis/linux-do/search.yaml | 48 ---------- clis/linux-do/tags.ts | 40 +++++++++ clis/linux-do/tags.yaml | 41 --------- clis/linux-do/topic-content.test.ts | 10 +-- clis/linux-do/topic.ts | 57 ++++++++++++ clis/linux-do/topic.yaml | 62 ------------- clis/linux-do/user-posts.ts | 62 +++++++++++++ clis/linux-do/user-posts.yaml | 67 -------------- clis/linux-do/user-topics.ts | 49 ++++++++++ clis/linux-do/user-topics.yaml | 54 ----------- clis/lobsters/active.ts | 27 ++++++ clis/lobsters/active.yaml | 29 ------ clis/lobsters/hot.ts | 27 ++++++ clis/lobsters/hot.yaml | 29 ------ clis/lobsters/newest.ts | 27 ++++++ clis/lobsters/newest.yaml | 29 ------ clis/lobsters/tag.ts | 33 +++++++ clis/lobsters/tag.yaml | 34 ------- clis/pixiv/detail.ts | 59 ++++++++++++ clis/pixiv/detail.yaml | 49 ---------- clis/pixiv/ranking.ts | 60 +++++++++++++ clis/pixiv/ranking.yaml | 53 ----------- clis/pixiv/user.ts | 53 +++++++++++ clis/pixiv/user.yaml | 46 ---------- clis/reddit/frontpage.ts | 32 +++++++ clis/reddit/frontpage.yaml | 30 ------- clis/reddit/hot.ts | 46 ++++++++++ clis/reddit/hot.yaml | 47 ---------- clis/reddit/popular.ts | 42 +++++++++ clis/reddit/popular.yaml | 40 --------- clis/reddit/search.ts | 66 ++++++++++++++ clis/reddit/search.yaml | 61 ------------- clis/reddit/subreddit.ts | 53 +++++++++++ clis/reddit/subreddit.yaml | 50 ----------- clis/reddit/user-comments.ts | 45 ++++++++++ clis/reddit/user-comments.yaml | 46 ---------- clis/reddit/user-posts.ts | 43 +++++++++ clis/reddit/user-posts.yaml | 44 --------- clis/reddit/user.ts | 38 ++++++++ clis/reddit/user.yaml | 40 --------- clis/stackoverflow/bounties.ts | 28 ++++++ clis/stackoverflow/bounties.yaml | 29 ------ clis/stackoverflow/hot.ts | 25 ++++++ clis/stackoverflow/hot.yaml | 28 ------ clis/stackoverflow/search.ts | 28 ++++++ clis/stackoverflow/search.yaml | 33 ------- clis/stackoverflow/unanswered.ts | 27 ++++++ clis/stackoverflow/unanswered.yaml | 28 ------ clis/steam/top-sellers.ts | 26 ++++++ clis/steam/top-sellers.yaml | 29 ------ clis/tiktok/comment.ts | 58 ++++++++++++ clis/tiktok/comment.yaml | 66 -------------- clis/tiktok/explore.ts | 36 ++++++++ clis/tiktok/explore.yaml | 39 -------- clis/tiktok/follow.ts | 40 +++++++++ clis/tiktok/follow.yaml | 39 -------- clis/tiktok/following.ts | 43 +++++++++ clis/tiktok/following.yaml | 46 ---------- clis/tiktok/friends.ts | 44 +++++++++ clis/tiktok/friends.yaml | 47 ---------- clis/tiktok/like.ts | 34 +++++++ clis/tiktok/like.yaml | 38 -------- clis/tiktok/live.ts | 48 ++++++++++ clis/tiktok/live.yaml | 51 ----------- clis/tiktok/notifications.ts | 50 +++++++++++ clis/tiktok/notifications.yaml | 52 ----------- clis/tiktok/profile.ts | 55 ++++++++++++ clis/tiktok/profile.yaml | 45 ---------- clis/tiktok/save.ts | 30 +++++++ clis/tiktok/save.yaml | 34 ------- clis/tiktok/search.ts | 40 +++++++++ clis/tiktok/search.yaml | 47 ---------- clis/tiktok/unfollow.ts | 45 ++++++++++ clis/tiktok/unfollow.yaml | 44 --------- clis/tiktok/unlike.ts | 34 +++++++ clis/tiktok/unlike.yaml | 38 -------- clis/tiktok/unsave.ts | 32 +++++++ clis/tiktok/unsave.yaml | 36 -------- clis/tiktok/user.ts | 42 +++++++++ clis/tiktok/user.yaml | 44 --------- clis/v2ex/hot.ts | 26 ++++++ clis/v2ex/hot.yaml | 28 ------ clis/v2ex/latest.ts | 26 ++++++ clis/v2ex/latest.yaml | 28 ------ clis/v2ex/member.ts | 28 ++++++ clis/v2ex/member.yaml | 29 ------ clis/v2ex/node.ts | 39 ++++++++ clis/v2ex/node.yaml | 34 ------- clis/v2ex/nodes.ts | 26 ++++++ clis/v2ex/nodes.yaml | 31 ------- clis/v2ex/replies.ts | 27 ++++++ clis/v2ex/replies.yaml | 32 ------- clis/v2ex/topic.ts | 31 +++++++ clis/v2ex/topic.yaml | 33 ------- clis/v2ex/user.ts | 34 +++++++ clis/v2ex/user.yaml | 34 ------- clis/xiaoe/catalog.ts | 126 ++++++++++++++++++++++++++ clis/xiaoe/catalog.yaml | 129 -------------------------- clis/xiaoe/content.ts | 40 +++++++++ clis/xiaoe/content.yaml | 43 --------- clis/xiaoe/courses.ts | 70 +++++++++++++++ clis/xiaoe/courses.yaml | 73 --------------- clis/xiaoe/detail.ts | 36 ++++++++ clis/xiaoe/detail.yaml | 39 -------- clis/xiaoe/play-url.ts | 121 +++++++++++++++++++++++++ clis/xiaoe/play-url.yaml | 124 ------------------------- clis/xiaohongshu/feed.ts | 33 +++++++ clis/xiaohongshu/feed.yaml | 31 ------- clis/xiaohongshu/notifications.ts | 39 ++++++++ clis/xiaohongshu/notifications.yaml | 37 -------- clis/xueqiu/earnings-date.ts | 62 +++++++++++++ clis/xueqiu/earnings-date.yaml | 69 -------------- clis/xueqiu/feed.ts | 49 ++++++++++ clis/xueqiu/feed.yaml | 53 ----------- clis/xueqiu/groups.ts | 26 ++++++ clis/xueqiu/groups.yaml | 23 ----- clis/xueqiu/hot-stock.ts | 45 ++++++++++ clis/xueqiu/hot-stock.yaml | 49 ---------- clis/xueqiu/hot.ts | 45 ++++++++++ clis/xueqiu/hot.yaml | 46 ---------- clis/xueqiu/kline.ts | 65 ++++++++++++++ clis/xueqiu/kline.yaml | 65 -------------- clis/xueqiu/search.ts | 50 +++++++++++ clis/xueqiu/search.yaml | 55 ------------ clis/xueqiu/stock.ts | 73 +++++++++++++++ clis/xueqiu/stock.yaml | 69 -------------- clis/xueqiu/watchlist.ts | 46 ++++++++++ clis/xueqiu/watchlist.yaml | 46 ---------- clis/zhihu/hot.ts | 44 +++++++++ clis/zhihu/hot.yaml | 46 ---------- clis/zhihu/search.ts | 53 +++++++++++ clis/zhihu/search.yaml | 59 ------------ src/build-manifest.test.ts | 48 +--------- src/build-manifest.ts | 88 ++---------------- src/discovery.ts | 134 +++++++--------------------- src/engine.test.ts | 23 +---- src/generate-verified.test.ts | 57 ++++++------ src/generate-verified.ts | 104 +++++++++++++++++---- src/synthesize.ts | 13 ++- 255 files changed, 5447 insertions(+), 5798 deletions(-) create mode 100644 clis/bilibili/hot.ts delete mode 100644 clis/bilibili/hot.yaml create mode 100644 clis/bluesky/feeds.ts delete mode 100644 clis/bluesky/feeds.yaml create mode 100644 clis/bluesky/followers.ts delete mode 100644 clis/bluesky/followers.yaml create mode 100644 clis/bluesky/following.ts delete mode 100644 clis/bluesky/following.yaml create mode 100644 clis/bluesky/profile.ts delete mode 100644 clis/bluesky/profile.yaml create mode 100644 clis/bluesky/search.ts delete mode 100644 clis/bluesky/search.yaml create mode 100644 clis/bluesky/starter-packs.ts delete mode 100644 clis/bluesky/starter-packs.yaml create mode 100644 clis/bluesky/thread.ts delete mode 100644 clis/bluesky/thread.yaml create mode 100644 clis/bluesky/trending.ts delete mode 100644 clis/bluesky/trending.yaml create mode 100644 clis/bluesky/user.ts delete mode 100644 clis/bluesky/user.yaml create mode 100644 clis/devto/tag.ts delete mode 100644 clis/devto/tag.yaml create mode 100644 clis/devto/top.ts delete mode 100644 clis/devto/top.yaml create mode 100644 clis/devto/user.ts delete mode 100644 clis/devto/user.yaml create mode 100644 clis/dictionary/examples.ts delete mode 100644 clis/dictionary/examples.yaml create mode 100644 clis/dictionary/search.ts delete mode 100644 clis/dictionary/search.yaml create mode 100644 clis/dictionary/synonyms.ts delete mode 100644 clis/dictionary/synonyms.yaml create mode 100644 clis/douban/subject.ts delete mode 100644 clis/douban/subject.yaml create mode 100644 clis/douban/top250.ts delete mode 100644 clis/douban/top250.yaml create mode 100644 clis/facebook/add-friend.ts delete mode 100644 clis/facebook/add-friend.yaml create mode 100644 clis/facebook/events.ts delete mode 100644 clis/facebook/events.yaml create mode 100644 clis/facebook/feed.ts delete mode 100644 clis/facebook/feed.yaml create mode 100644 clis/facebook/friends.ts delete mode 100644 clis/facebook/friends.yaml create mode 100644 clis/facebook/groups.ts delete mode 100644 clis/facebook/groups.yaml create mode 100644 clis/facebook/join-group.ts delete mode 100644 clis/facebook/join-group.yaml create mode 100644 clis/facebook/memories.ts delete mode 100644 clis/facebook/memories.yaml create mode 100644 clis/facebook/notifications.ts delete mode 100644 clis/facebook/notifications.yaml create mode 100644 clis/facebook/profile.ts delete mode 100644 clis/facebook/profile.yaml create mode 100644 clis/facebook/search.ts delete mode 100644 clis/facebook/search.yaml create mode 100644 clis/hackernews/ask.ts delete mode 100644 clis/hackernews/ask.yaml create mode 100644 clis/hackernews/best.ts delete mode 100644 clis/hackernews/best.yaml create mode 100644 clis/hackernews/jobs.ts delete mode 100644 clis/hackernews/jobs.yaml create mode 100644 clis/hackernews/new.ts delete mode 100644 clis/hackernews/new.yaml create mode 100644 clis/hackernews/search.ts delete mode 100644 clis/hackernews/search.yaml create mode 100644 clis/hackernews/show.ts delete mode 100644 clis/hackernews/show.yaml create mode 100644 clis/hackernews/top.ts delete mode 100644 clis/hackernews/top.yaml create mode 100644 clis/hackernews/user.ts delete mode 100644 clis/hackernews/user.yaml create mode 100644 clis/hupu/hot.ts delete mode 100644 clis/hupu/hot.yaml create mode 100644 clis/instagram/comment.ts delete mode 100644 clis/instagram/comment.yaml create mode 100644 clis/instagram/explore.ts delete mode 100644 clis/instagram/explore.yaml create mode 100644 clis/instagram/follow.ts delete mode 100644 clis/instagram/follow.yaml create mode 100644 clis/instagram/followers.ts delete mode 100644 clis/instagram/followers.yaml create mode 100644 clis/instagram/following.ts delete mode 100644 clis/instagram/following.yaml create mode 100644 clis/instagram/like.ts delete mode 100644 clis/instagram/like.yaml create mode 100644 clis/instagram/profile.ts delete mode 100644 clis/instagram/profile.yaml create mode 100644 clis/instagram/save.ts delete mode 100644 clis/instagram/save.yaml create mode 100644 clis/instagram/saved.ts delete mode 100644 clis/instagram/saved.yaml create mode 100644 clis/instagram/search.ts delete mode 100644 clis/instagram/search.yaml create mode 100644 clis/instagram/unfollow.ts delete mode 100644 clis/instagram/unfollow.yaml create mode 100644 clis/instagram/unlike.ts delete mode 100644 clis/instagram/unlike.yaml create mode 100644 clis/instagram/unsave.ts delete mode 100644 clis/instagram/unsave.yaml create mode 100644 clis/instagram/user.ts delete mode 100644 clis/instagram/user.yaml create mode 100644 clis/jike/post.ts delete mode 100644 clis/jike/post.yaml create mode 100644 clis/jike/topic.ts delete mode 100644 clis/jike/topic.yaml create mode 100644 clis/jike/user.ts delete mode 100644 clis/jike/user.yaml create mode 100644 clis/jimeng/generate.ts delete mode 100644 clis/jimeng/generate.yaml create mode 100644 clis/jimeng/history.ts delete mode 100644 clis/jimeng/history.yaml create mode 100644 clis/linux-do/categories.ts delete mode 100644 clis/linux-do/categories.yaml create mode 100644 clis/linux-do/search.ts delete mode 100644 clis/linux-do/search.yaml create mode 100644 clis/linux-do/tags.ts delete mode 100644 clis/linux-do/tags.yaml create mode 100644 clis/linux-do/topic.ts delete mode 100644 clis/linux-do/topic.yaml create mode 100644 clis/linux-do/user-posts.ts delete mode 100644 clis/linux-do/user-posts.yaml create mode 100644 clis/linux-do/user-topics.ts delete mode 100644 clis/linux-do/user-topics.yaml create mode 100644 clis/lobsters/active.ts delete mode 100644 clis/lobsters/active.yaml create mode 100644 clis/lobsters/hot.ts delete mode 100644 clis/lobsters/hot.yaml create mode 100644 clis/lobsters/newest.ts delete mode 100644 clis/lobsters/newest.yaml create mode 100644 clis/lobsters/tag.ts delete mode 100644 clis/lobsters/tag.yaml create mode 100644 clis/pixiv/detail.ts delete mode 100644 clis/pixiv/detail.yaml create mode 100644 clis/pixiv/ranking.ts delete mode 100644 clis/pixiv/ranking.yaml create mode 100644 clis/pixiv/user.ts delete mode 100644 clis/pixiv/user.yaml create mode 100644 clis/reddit/frontpage.ts delete mode 100644 clis/reddit/frontpage.yaml create mode 100644 clis/reddit/hot.ts delete mode 100644 clis/reddit/hot.yaml create mode 100644 clis/reddit/popular.ts delete mode 100644 clis/reddit/popular.yaml create mode 100644 clis/reddit/search.ts delete mode 100644 clis/reddit/search.yaml create mode 100644 clis/reddit/subreddit.ts delete mode 100644 clis/reddit/subreddit.yaml create mode 100644 clis/reddit/user-comments.ts delete mode 100644 clis/reddit/user-comments.yaml create mode 100644 clis/reddit/user-posts.ts delete mode 100644 clis/reddit/user-posts.yaml create mode 100644 clis/reddit/user.ts delete mode 100644 clis/reddit/user.yaml create mode 100644 clis/stackoverflow/bounties.ts delete mode 100644 clis/stackoverflow/bounties.yaml create mode 100644 clis/stackoverflow/hot.ts delete mode 100644 clis/stackoverflow/hot.yaml create mode 100644 clis/stackoverflow/search.ts delete mode 100644 clis/stackoverflow/search.yaml create mode 100644 clis/stackoverflow/unanswered.ts delete mode 100644 clis/stackoverflow/unanswered.yaml create mode 100644 clis/steam/top-sellers.ts delete mode 100644 clis/steam/top-sellers.yaml create mode 100644 clis/tiktok/comment.ts delete mode 100644 clis/tiktok/comment.yaml create mode 100644 clis/tiktok/explore.ts delete mode 100644 clis/tiktok/explore.yaml create mode 100644 clis/tiktok/follow.ts delete mode 100644 clis/tiktok/follow.yaml create mode 100644 clis/tiktok/following.ts delete mode 100644 clis/tiktok/following.yaml create mode 100644 clis/tiktok/friends.ts delete mode 100644 clis/tiktok/friends.yaml create mode 100644 clis/tiktok/like.ts delete mode 100644 clis/tiktok/like.yaml create mode 100644 clis/tiktok/live.ts delete mode 100644 clis/tiktok/live.yaml create mode 100644 clis/tiktok/notifications.ts delete mode 100644 clis/tiktok/notifications.yaml create mode 100644 clis/tiktok/profile.ts delete mode 100644 clis/tiktok/profile.yaml create mode 100644 clis/tiktok/save.ts delete mode 100644 clis/tiktok/save.yaml create mode 100644 clis/tiktok/search.ts delete mode 100644 clis/tiktok/search.yaml create mode 100644 clis/tiktok/unfollow.ts delete mode 100644 clis/tiktok/unfollow.yaml create mode 100644 clis/tiktok/unlike.ts delete mode 100644 clis/tiktok/unlike.yaml create mode 100644 clis/tiktok/unsave.ts delete mode 100644 clis/tiktok/unsave.yaml create mode 100644 clis/tiktok/user.ts delete mode 100644 clis/tiktok/user.yaml create mode 100644 clis/v2ex/hot.ts delete mode 100644 clis/v2ex/hot.yaml create mode 100644 clis/v2ex/latest.ts delete mode 100644 clis/v2ex/latest.yaml create mode 100644 clis/v2ex/member.ts delete mode 100644 clis/v2ex/member.yaml create mode 100644 clis/v2ex/node.ts delete mode 100644 clis/v2ex/node.yaml create mode 100644 clis/v2ex/nodes.ts delete mode 100644 clis/v2ex/nodes.yaml create mode 100644 clis/v2ex/replies.ts delete mode 100644 clis/v2ex/replies.yaml create mode 100644 clis/v2ex/topic.ts delete mode 100644 clis/v2ex/topic.yaml create mode 100644 clis/v2ex/user.ts delete mode 100644 clis/v2ex/user.yaml create mode 100644 clis/xiaoe/catalog.ts delete mode 100644 clis/xiaoe/catalog.yaml create mode 100644 clis/xiaoe/content.ts delete mode 100644 clis/xiaoe/content.yaml create mode 100644 clis/xiaoe/courses.ts delete mode 100644 clis/xiaoe/courses.yaml create mode 100644 clis/xiaoe/detail.ts delete mode 100644 clis/xiaoe/detail.yaml create mode 100644 clis/xiaoe/play-url.ts delete mode 100644 clis/xiaoe/play-url.yaml create mode 100644 clis/xiaohongshu/feed.ts delete mode 100644 clis/xiaohongshu/feed.yaml create mode 100644 clis/xiaohongshu/notifications.ts delete mode 100644 clis/xiaohongshu/notifications.yaml create mode 100644 clis/xueqiu/earnings-date.ts delete mode 100644 clis/xueqiu/earnings-date.yaml create mode 100644 clis/xueqiu/feed.ts delete mode 100644 clis/xueqiu/feed.yaml create mode 100644 clis/xueqiu/groups.ts delete mode 100644 clis/xueqiu/groups.yaml create mode 100644 clis/xueqiu/hot-stock.ts delete mode 100644 clis/xueqiu/hot-stock.yaml create mode 100644 clis/xueqiu/hot.ts delete mode 100644 clis/xueqiu/hot.yaml create mode 100644 clis/xueqiu/kline.ts delete mode 100644 clis/xueqiu/kline.yaml create mode 100644 clis/xueqiu/search.ts delete mode 100644 clis/xueqiu/search.yaml create mode 100644 clis/xueqiu/stock.ts delete mode 100644 clis/xueqiu/stock.yaml create mode 100644 clis/xueqiu/watchlist.ts delete mode 100644 clis/xueqiu/watchlist.yaml create mode 100644 clis/zhihu/hot.ts delete mode 100644 clis/zhihu/hot.yaml create mode 100644 clis/zhihu/search.ts delete mode 100644 clis/zhihu/search.yaml diff --git a/clis/bilibili/hot.ts b/clis/bilibili/hot.ts new file mode 100644 index 00000000..ab8f44e3 --- /dev/null +++ b/clis/bilibili/hot.ts @@ -0,0 +1,36 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'bilibili', + name: 'hot', + description: 'B站热门视频', + domain: 'www.bilibili.com', + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of videos' }, + ], + columns: ['rank', 'title', 'author', 'play', 'danmaku'], + pipeline: [ + { navigate: 'https://www.bilibili.com' }, + { evaluate: `(async () => { + const res = await fetch('https://api.bilibili.com/x/web-interface/popular?ps=\${{ args.limit }}&pn=1', { + credentials: 'include' + }); + const data = await res.json(); + return (data?.data?.list || []).map((item) => ({ + title: item.title, + author: item.owner?.name, + play: item.stat?.view, + danmaku: item.stat?.danmaku, + })); +})() +` }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + author: '${{ item.author }}', + play: '${{ item.play }}', + danmaku: '${{ item.danmaku }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/bilibili/hot.yaml b/clis/bilibili/hot.yaml deleted file mode 100644 index 4bee77b0..00000000 --- a/clis/bilibili/hot.yaml +++ /dev/null @@ -1,38 +0,0 @@ -site: bilibili -name: hot -description: B站热门视频 -domain: www.bilibili.com - -args: - limit: - type: int - default: 20 - description: Number of videos - -pipeline: - - navigate: https://www.bilibili.com - - - evaluate: | - (async () => { - const res = await fetch('https://api.bilibili.com/x/web-interface/popular?ps=${{ args.limit }}&pn=1', { - credentials: 'include' - }); - const data = await res.json(); - return (data?.data?.list || []).map((item) => ({ - title: item.title, - author: item.owner?.name, - play: item.stat?.view, - danmaku: item.stat?.danmaku, - })); - })() - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - author: ${{ item.author }} - play: ${{ item.play }} - danmaku: ${{ item.danmaku }} - - - limit: ${{ args.limit }} - -columns: [rank, title, author, play, danmaku] diff --git a/clis/bluesky/feeds.ts b/clis/bluesky/feeds.ts new file mode 100644 index 00000000..58d2ee78 --- /dev/null +++ b/clis/bluesky/feeds.ts @@ -0,0 +1,28 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'bluesky', + name: 'feeds', + description: 'Popular Bluesky feed generators', + domain: 'public.api.bsky.app', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of feeds' }, + ], + columns: ['rank', 'name', 'likes', 'creator', 'description'], + pipeline: [ + { fetch: { + url: 'https://public.api.bsky.app/xrpc/app.bsky.unspecced.getPopularFeedGenerators?limit=${{ args.limit }}', + } }, + { select: 'feeds' }, + { map: { + rank: '${{ index + 1 }}', + name: '${{ item.displayName }}', + likes: '${{ item.likeCount }}', + creator: '${{ item.creator.handle }}', + description: '${{ item.description }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/bluesky/feeds.yaml b/clis/bluesky/feeds.yaml deleted file mode 100644 index 54e032eb..00000000 --- a/clis/bluesky/feeds.yaml +++ /dev/null @@ -1,29 +0,0 @@ -site: bluesky -name: feeds -description: Popular Bluesky feed generators -domain: public.api.bsky.app -strategy: public -browser: false - -args: - limit: - type: int - default: 20 - description: Number of feeds - -pipeline: - - fetch: - url: https://public.api.bsky.app/xrpc/app.bsky.unspecced.getPopularFeedGenerators?limit=${{ args.limit }} - - - select: feeds - - - map: - rank: ${{ index + 1 }} - name: ${{ item.displayName }} - likes: ${{ item.likeCount }} - creator: ${{ item.creator.handle }} - description: ${{ item.description }} - - - limit: ${{ args.limit }} - -columns: [rank, name, likes, creator, description] diff --git a/clis/bluesky/followers.ts b/clis/bluesky/followers.ts new file mode 100644 index 00000000..984783a5 --- /dev/null +++ b/clis/bluesky/followers.ts @@ -0,0 +1,28 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'bluesky', + name: 'followers', + description: 'List followers of a Bluesky user', + domain: 'public.api.bsky.app', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'handle', required: true, positional: true, help: 'Bluesky handle' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of followers' }, + ], + columns: ['rank', 'handle', 'name', 'description'], + pipeline: [ + { fetch: { + url: 'https://public.api.bsky.app/xrpc/app.bsky.graph.getFollowers?actor=${{ args.handle }}&limit=${{ args.limit }}', + } }, + { select: 'followers' }, + { map: { + rank: '${{ index + 1 }}', + handle: '${{ item.handle }}', + name: '${{ item.displayName }}', + description: '${{ item.description }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/bluesky/followers.yaml b/clis/bluesky/followers.yaml deleted file mode 100644 index 382d466d..00000000 --- a/clis/bluesky/followers.yaml +++ /dev/null @@ -1,33 +0,0 @@ -site: bluesky -name: followers -description: List followers of a Bluesky user -domain: public.api.bsky.app -strategy: public -browser: false - -args: - handle: - type: str - required: true - positional: true - description: "Bluesky handle" - limit: - type: int - default: 20 - description: Number of followers - -pipeline: - - fetch: - url: https://public.api.bsky.app/xrpc/app.bsky.graph.getFollowers?actor=${{ args.handle }}&limit=${{ args.limit }} - - - select: followers - - - map: - rank: ${{ index + 1 }} - handle: ${{ item.handle }} - name: ${{ item.displayName }} - description: ${{ item.description }} - - - limit: ${{ args.limit }} - -columns: [rank, handle, name, description] diff --git a/clis/bluesky/following.ts b/clis/bluesky/following.ts new file mode 100644 index 00000000..4c9ed05d --- /dev/null +++ b/clis/bluesky/following.ts @@ -0,0 +1,28 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'bluesky', + name: 'following', + description: 'List accounts a Bluesky user is following', + domain: 'public.api.bsky.app', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'handle', required: true, positional: true, help: 'Bluesky handle' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of accounts' }, + ], + columns: ['rank', 'handle', 'name', 'description'], + pipeline: [ + { fetch: { + url: 'https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=${{ args.handle }}&limit=${{ args.limit }}', + } }, + { select: 'follows' }, + { map: { + rank: '${{ index + 1 }}', + handle: '${{ item.handle }}', + name: '${{ item.displayName }}', + description: '${{ item.description }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/bluesky/following.yaml b/clis/bluesky/following.yaml deleted file mode 100644 index 49f29fcb..00000000 --- a/clis/bluesky/following.yaml +++ /dev/null @@ -1,33 +0,0 @@ -site: bluesky -name: following -description: List accounts a Bluesky user is following -domain: public.api.bsky.app -strategy: public -browser: false - -args: - handle: - type: str - required: true - positional: true - description: "Bluesky handle" - limit: - type: int - default: 20 - description: Number of accounts - -pipeline: - - fetch: - url: https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=${{ args.handle }}&limit=${{ args.limit }} - - - select: follows - - - map: - rank: ${{ index + 1 }} - handle: ${{ item.handle }} - name: ${{ item.displayName }} - description: ${{ item.description }} - - - limit: ${{ args.limit }} - -columns: [rank, handle, name, description] diff --git a/clis/bluesky/profile.ts b/clis/bluesky/profile.ts new file mode 100644 index 00000000..5658eb30 --- /dev/null +++ b/clis/bluesky/profile.ts @@ -0,0 +1,30 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'bluesky', + name: 'profile', + description: 'Get Bluesky user profile info', + domain: 'public.api.bsky.app', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { + name: 'handle', + required: true, + positional: true, + help: 'Bluesky handle (e.g. bsky.app, jay.bsky.team)', + }, + ], + columns: ['handle', 'name', 'followers', 'following', 'posts', 'description'], + pipeline: [ + { fetch: { url: 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${{ args.handle }}' } }, + { map: { + handle: '${{ item.handle }}', + name: '${{ item.displayName }}', + followers: '${{ item.followersCount }}', + following: '${{ item.followsCount }}', + posts: '${{ item.postsCount }}', + description: '${{ item.description }}', + } }, + ], +}); diff --git a/clis/bluesky/profile.yaml b/clis/bluesky/profile.yaml deleted file mode 100644 index ab8f441a..00000000 --- a/clis/bluesky/profile.yaml +++ /dev/null @@ -1,27 +0,0 @@ -site: bluesky -name: profile -description: Get Bluesky user profile info -domain: public.api.bsky.app -strategy: public -browser: false - -args: - handle: - type: str - required: true - positional: true - description: "Bluesky handle (e.g. bsky.app, jay.bsky.team)" - -pipeline: - - fetch: - url: https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${{ args.handle }} - - - map: - handle: ${{ item.handle }} - name: ${{ item.displayName }} - followers: ${{ item.followersCount }} - following: ${{ item.followsCount }} - posts: ${{ item.postsCount }} - description: ${{ item.description }} - -columns: [handle, name, followers, following, posts, description] diff --git a/clis/bluesky/search.ts b/clis/bluesky/search.ts new file mode 100644 index 00000000..7cf6d82d --- /dev/null +++ b/clis/bluesky/search.ts @@ -0,0 +1,29 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'bluesky', + name: 'search', + description: 'Search Bluesky users', + domain: 'public.api.bsky.app', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'query', required: true, positional: true, help: 'Search query' }, + { name: 'limit', type: 'int', default: 10, help: 'Number of results' }, + ], + columns: ['rank', 'handle', 'name', 'followers', 'description'], + pipeline: [ + { fetch: { + url: 'https://public.api.bsky.app/xrpc/app.bsky.actor.searchActors?q=${{ args.query }}&limit=${{ args.limit }}', + } }, + { select: 'actors' }, + { map: { + rank: '${{ index + 1 }}', + handle: '${{ item.handle }}', + name: '${{ item.displayName }}', + followers: '${{ item.followersCount }}', + description: '${{ item.description }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/bluesky/search.yaml b/clis/bluesky/search.yaml deleted file mode 100644 index 297fe15a..00000000 --- a/clis/bluesky/search.yaml +++ /dev/null @@ -1,34 +0,0 @@ -site: bluesky -name: search -description: Search Bluesky users -domain: public.api.bsky.app -strategy: public -browser: false - -args: - query: - type: str - required: true - positional: true - description: Search query - limit: - type: int - default: 10 - description: Number of results - -pipeline: - - fetch: - url: https://public.api.bsky.app/xrpc/app.bsky.actor.searchActors?q=${{ args.query }}&limit=${{ args.limit }} - - - select: actors - - - map: - rank: ${{ index + 1 }} - handle: ${{ item.handle }} - name: ${{ item.displayName }} - followers: ${{ item.followersCount }} - description: ${{ item.description }} - - - limit: ${{ args.limit }} - -columns: [rank, handle, name, followers, description] diff --git a/clis/bluesky/starter-packs.ts b/clis/bluesky/starter-packs.ts new file mode 100644 index 00000000..7559fa64 --- /dev/null +++ b/clis/bluesky/starter-packs.ts @@ -0,0 +1,29 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'bluesky', + name: 'starter-packs', + description: 'Get starter packs created by a Bluesky user', + domain: 'public.api.bsky.app', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'handle', required: true, positional: true, help: 'Bluesky handle' }, + { name: 'limit', type: 'int', default: 10, help: 'Number of starter packs' }, + ], + columns: ['rank', 'name', 'description', 'members', 'joins'], + pipeline: [ + { fetch: { + url: 'https://public.api.bsky.app/xrpc/app.bsky.graph.getActorStarterPacks?actor=${{ args.handle }}&limit=${{ args.limit }}', + } }, + { select: 'starterPacks' }, + { map: { + rank: '${{ index + 1 }}', + name: '${{ item.record.name }}', + description: '${{ item.record.description }}', + members: '${{ item.listItemCount }}', + joins: '${{ item.joinedAllTimeCount }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/bluesky/starter-packs.yaml b/clis/bluesky/starter-packs.yaml deleted file mode 100644 index de483ff9..00000000 --- a/clis/bluesky/starter-packs.yaml +++ /dev/null @@ -1,34 +0,0 @@ -site: bluesky -name: starter-packs -description: Get starter packs created by a Bluesky user -domain: public.api.bsky.app -strategy: public -browser: false - -args: - handle: - type: str - required: true - positional: true - description: "Bluesky handle" - limit: - type: int - default: 10 - description: Number of starter packs - -pipeline: - - fetch: - url: https://public.api.bsky.app/xrpc/app.bsky.graph.getActorStarterPacks?actor=${{ args.handle }}&limit=${{ args.limit }} - - - select: starterPacks - - - map: - rank: ${{ index + 1 }} - name: ${{ item.record.name }} - description: ${{ item.record.description }} - members: ${{ item.listItemCount }} - joins: ${{ item.joinedAllTimeCount }} - - - limit: ${{ args.limit }} - -columns: [rank, name, description, members, joins] diff --git a/clis/bluesky/thread.ts b/clis/bluesky/thread.ts new file mode 100644 index 00000000..fd5996e3 --- /dev/null +++ b/clis/bluesky/thread.ts @@ -0,0 +1,31 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'bluesky', + name: 'thread', + description: 'Get a Bluesky post thread with replies', + domain: 'public.api.bsky.app', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { + name: 'uri', + required: true, + positional: true, + help: 'Post AT URI (at://did:.../app.bsky.feed.post/...) or bsky.app URL', + }, + { name: 'limit', type: 'int', default: 20, help: 'Number of replies' }, + ], + columns: ['author', 'text', 'likes', 'reposts', 'replies_count'], + pipeline: [ + { fetch: { url: 'https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${{ args.uri }}&depth=2' } }, + { select: 'thread' }, + { map: { + author: '${{ item.post.author.handle }}', + text: '${{ item.post.record.text }}', + likes: '${{ item.post.likeCount }}', + reposts: '${{ item.post.repostCount }}', + replies_count: '${{ item.post.replyCount }}', + } }, + ], +}); diff --git a/clis/bluesky/thread.yaml b/clis/bluesky/thread.yaml deleted file mode 100644 index 99315a4f..00000000 --- a/clis/bluesky/thread.yaml +++ /dev/null @@ -1,32 +0,0 @@ -site: bluesky -name: thread -description: Get a Bluesky post thread with replies -domain: public.api.bsky.app -strategy: public -browser: false - -args: - uri: - type: str - required: true - positional: true - description: "Post AT URI (at://did:.../app.bsky.feed.post/...) or bsky.app URL" - limit: - type: int - default: 20 - description: Number of replies - -pipeline: - - fetch: - url: https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${{ args.uri }}&depth=2 - - - select: thread - - - map: - author: ${{ item.post.author.handle }} - text: ${{ item.post.record.text }} - likes: ${{ item.post.likeCount }} - reposts: ${{ item.post.repostCount }} - replies_count: ${{ item.post.replyCount }} - -columns: [author, text, likes, reposts, replies_count] diff --git a/clis/bluesky/trending.ts b/clis/bluesky/trending.ts new file mode 100644 index 00000000..08ae4ed7 --- /dev/null +++ b/clis/bluesky/trending.ts @@ -0,0 +1,20 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'bluesky', + name: 'trending', + description: 'Trending topics on Bluesky', + domain: 'public.api.bsky.app', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of topics' }, + ], + columns: ['rank', 'topic', 'link'], + pipeline: [ + { fetch: { url: 'https://public.api.bsky.app/xrpc/app.bsky.unspecced.getTrendingTopics' } }, + { select: 'topics' }, + { map: { rank: '${{ index + 1 }}', topic: '${{ item.topic }}', link: '${{ item.link }}' } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/bluesky/trending.yaml b/clis/bluesky/trending.yaml deleted file mode 100644 index 28db07bd..00000000 --- a/clis/bluesky/trending.yaml +++ /dev/null @@ -1,27 +0,0 @@ -site: bluesky -name: trending -description: Trending topics on Bluesky -domain: public.api.bsky.app -strategy: public -browser: false - -args: - limit: - type: int - default: 20 - description: Number of topics - -pipeline: - - fetch: - url: https://public.api.bsky.app/xrpc/app.bsky.unspecced.getTrendingTopics - - - select: topics - - - map: - rank: ${{ index + 1 }} - topic: ${{ item.topic }} - link: ${{ item.link }} - - - limit: ${{ args.limit }} - -columns: [rank, topic, link] diff --git a/clis/bluesky/user.ts b/clis/bluesky/user.ts new file mode 100644 index 00000000..8e3f05c1 --- /dev/null +++ b/clis/bluesky/user.ts @@ -0,0 +1,34 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'bluesky', + name: 'user', + description: 'Get recent posts from a Bluesky user', + domain: 'public.api.bsky.app', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { + name: 'handle', + required: true, + positional: true, + help: 'Bluesky handle (e.g. bsky.app)', + }, + { name: 'limit', type: 'int', default: 20, help: 'Number of posts' }, + ], + columns: ['rank', 'text', 'likes', 'reposts', 'replies'], + pipeline: [ + { fetch: { + url: 'https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${{ args.handle }}&limit=${{ args.limit }}', + } }, + { select: 'feed' }, + { map: { + rank: '${{ index + 1 }}', + text: '${{ item.post.record.text }}', + likes: '${{ item.post.likeCount }}', + reposts: '${{ item.post.repostCount }}', + replies: '${{ item.post.replyCount }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/bluesky/user.yaml b/clis/bluesky/user.yaml deleted file mode 100644 index 5d2eb2ff..00000000 --- a/clis/bluesky/user.yaml +++ /dev/null @@ -1,34 +0,0 @@ -site: bluesky -name: user -description: Get recent posts from a Bluesky user -domain: public.api.bsky.app -strategy: public -browser: false - -args: - handle: - type: str - required: true - positional: true - description: "Bluesky handle (e.g. bsky.app)" - limit: - type: int - default: 20 - description: Number of posts - -pipeline: - - fetch: - url: https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${{ args.handle }}&limit=${{ args.limit }} - - - select: feed - - - map: - rank: ${{ index + 1 }} - text: ${{ item.post.record.text }} - likes: ${{ item.post.likeCount }} - reposts: ${{ item.post.repostCount }} - replies: ${{ item.post.replyCount }} - - - limit: ${{ args.limit }} - -columns: [rank, text, likes, reposts, replies] diff --git a/clis/devto/tag.ts b/clis/devto/tag.ts new file mode 100644 index 00000000..0e470e80 --- /dev/null +++ b/clis/devto/tag.ts @@ -0,0 +1,33 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'devto', + name: 'tag', + description: 'Latest DEV.to articles for a specific tag', + domain: 'dev.to', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { + name: 'tag', + required: true, + positional: true, + help: 'Tag name (e.g. javascript, python, webdev)', + }, + { name: 'limit', type: 'int', default: 20, help: 'Number of articles' }, + ], + columns: ['rank', 'title', 'author', 'reactions', 'comments', 'tags'], + pipeline: [ + { fetch: { url: 'https://dev.to/api/articles?tag=${{ args.tag }}&per_page=${{ args.limit }}' } }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + author: '${{ item.user.username }}', + reactions: '${{ item.public_reactions_count }}', + comments: '${{ item.comments_count }}', + tags: `\${{ item.tag_list | join(', ') }}`, + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/devto/tag.yaml b/clis/devto/tag.yaml deleted file mode 100644 index f726eefb..00000000 --- a/clis/devto/tag.yaml +++ /dev/null @@ -1,34 +0,0 @@ -site: devto -name: tag -description: Latest DEV.to articles for a specific tag -domain: dev.to -strategy: public -browser: false - -args: - tag: - type: str - required: true - positional: true - description: "Tag name (e.g. javascript, python, webdev)" - limit: - type: int - default: 20 - description: Number of articles - -pipeline: - - fetch: - url: https://dev.to/api/articles?tag=${{ args.tag }}&per_page=${{ args.limit }} - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - author: ${{ item.user.username }} - reactions: ${{ item.public_reactions_count }} - comments: ${{ item.comments_count }} - tags: ${{ item.tag_list | join(', ') }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [rank, title, author, reactions, comments, tags] diff --git a/clis/devto/top.ts b/clis/devto/top.ts new file mode 100644 index 00000000..935872e0 --- /dev/null +++ b/clis/devto/top.ts @@ -0,0 +1,27 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'devto', + name: 'top', + description: 'Top DEV.to articles of the day', + domain: 'dev.to', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of articles' }, + ], + columns: ['rank', 'title', 'author', 'reactions', 'comments', 'tags'], + pipeline: [ + { fetch: { url: 'https://dev.to/api/articles?top=1&per_page=${{ args.limit }}' } }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + author: '${{ item.user.username }}', + reactions: '${{ item.public_reactions_count }}', + comments: '${{ item.comments_count }}', + tags: `\${{ item.tag_list | join(', ') }}`, + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/devto/top.yaml b/clis/devto/top.yaml deleted file mode 100644 index 567ddc5d..00000000 --- a/clis/devto/top.yaml +++ /dev/null @@ -1,29 +0,0 @@ -site: devto -name: top -description: Top DEV.to articles of the day -domain: dev.to -strategy: public -browser: false - -args: - limit: - type: int - default: 20 - description: Number of articles - -pipeline: - - fetch: - url: https://dev.to/api/articles?top=1&per_page=${{ args.limit }} - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - author: ${{ item.user.username }} - reactions: ${{ item.public_reactions_count }} - comments: ${{ item.comments_count }} - tags: ${{ item.tag_list | join(', ') }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [rank, title, author, reactions, comments, tags] diff --git a/clis/devto/user.ts b/clis/devto/user.ts new file mode 100644 index 00000000..088e08da --- /dev/null +++ b/clis/devto/user.ts @@ -0,0 +1,32 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'devto', + name: 'user', + description: 'Recent DEV.to articles from a specific user', + domain: 'dev.to', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { + name: 'username', + required: true, + positional: true, + help: 'DEV.to username (e.g. ben, thepracticaldev)', + }, + { name: 'limit', type: 'int', default: 20, help: 'Number of articles' }, + ], + columns: ['rank', 'title', 'reactions', 'comments', 'tags'], + pipeline: [ + { fetch: { url: 'https://dev.to/api/articles?username=${{ args.username }}&per_page=${{ args.limit }}' } }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + reactions: '${{ item.public_reactions_count }}', + comments: '${{ item.comments_count }}', + tags: `\${{ item.tag_list | join(', ') }}`, + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/devto/user.yaml b/clis/devto/user.yaml deleted file mode 100644 index c7ed1171..00000000 --- a/clis/devto/user.yaml +++ /dev/null @@ -1,33 +0,0 @@ -site: devto -name: user -description: Recent DEV.to articles from a specific user -domain: dev.to -strategy: public -browser: false - -args: - username: - type: str - required: true - positional: true - description: "DEV.to username (e.g. ben, thepracticaldev)" - limit: - type: int - default: 20 - description: Number of articles - -pipeline: - - fetch: - url: https://dev.to/api/articles?username=${{ args.username }}&per_page=${{ args.limit }} - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - reactions: ${{ item.public_reactions_count }} - comments: ${{ item.comments_count }} - tags: ${{ item.tag_list | join(', ') }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [rank, title, reactions, comments, tags] diff --git a/clis/dictionary/examples.ts b/clis/dictionary/examples.ts new file mode 100644 index 00000000..81ed066d --- /dev/null +++ b/clis/dictionary/examples.ts @@ -0,0 +1,28 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'dictionary', + name: 'examples', + description: 'Read real-world example sentences utilizing the word', + domain: 'api.dictionaryapi.dev', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { + name: 'word', + type: 'string', + required: true, + positional: true, + help: 'Word to get example sentences for', + }, + ], + columns: ['word', 'example'], + pipeline: [ + { fetch: { url: 'https://api.dictionaryapi.dev/api/v2/entries/en/${{ args.word | urlencode }}' } }, + { map: { + word: '${{ item.word }}', + example: `\${{ (() => { if (item.meanings) { for (const m of item.meanings) { if (m.definitions) { for (const d of m.definitions) { if (d.example) return d.example; } } } } return 'No example found in API.'; })() }}`, + } }, + { limit: 1 }, + ], +}); diff --git a/clis/dictionary/examples.yaml b/clis/dictionary/examples.yaml deleted file mode 100644 index 84633d6d..00000000 --- a/clis/dictionary/examples.yaml +++ /dev/null @@ -1,25 +0,0 @@ -site: dictionary -name: examples -description: Read real-world example sentences utilizing the word -domain: api.dictionaryapi.dev -strategy: public -browser: false - -args: - word: - type: string - required: true - positional: true - description: Word to get example sentences for - -pipeline: - - fetch: - url: "https://api.dictionaryapi.dev/api/v2/entries/en/${{ args.word | urlencode }}" - - - map: - word: "${{ item.word }}" - example: "${{ (() => { if (item.meanings) { for (const m of item.meanings) { if (m.definitions) { for (const d of m.definitions) { if (d.example) return d.example; } } } } return 'No example found in API.'; })() }}" - - - limit: 1 - -columns: [word, example] diff --git a/clis/dictionary/search.ts b/clis/dictionary/search.ts new file mode 100644 index 00000000..583e91a6 --- /dev/null +++ b/clis/dictionary/search.ts @@ -0,0 +1,30 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'dictionary', + name: 'search', + description: 'Search the Free Dictionary API for definitions, parts of speech, and pronunciations.', + domain: 'api.dictionaryapi.dev', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { + name: 'word', + type: 'string', + required: true, + positional: true, + help: 'Word to define (e.g., serendipity)', + }, + ], + columns: ['word', 'phonetic', 'type', 'definition'], + pipeline: [ + { fetch: { url: 'https://api.dictionaryapi.dev/api/v2/entries/en/${{ args.word | urlencode }}' } }, + { map: { + word: '${{ item.word }}', + phonetic: `\${{ (() => { if (item.phonetic) return item.phonetic; if (item.phonetics) { for (const p of item.phonetics) { if (p.text) return p.text; } } return ''; })() }}`, + type: `\${{ (() => { if (item.meanings && item.meanings[0] && item.meanings[0].partOfSpeech) return item.meanings[0].partOfSpeech; return 'N/A'; })() }}`, + definition: `\${{ (() => { if (item.meanings && item.meanings[0] && item.meanings[0].definitions && item.meanings[0].definitions[0] && item.meanings[0].definitions[0].definition) return item.meanings[0].definitions[0].definition; return 'No definition found in API.'; })() }}`, + } }, + { limit: 1 }, + ], +}); diff --git a/clis/dictionary/search.yaml b/clis/dictionary/search.yaml deleted file mode 100644 index b33472cc..00000000 --- a/clis/dictionary/search.yaml +++ /dev/null @@ -1,27 +0,0 @@ -site: dictionary -name: search -description: Search the Free Dictionary API for definitions, parts of speech, and pronunciations. -domain: api.dictionaryapi.dev -strategy: public -browser: false - -args: - word: - type: string - required: true - positional: true - description: Word to define (e.g., serendipity) - -pipeline: - - fetch: - url: "https://api.dictionaryapi.dev/api/v2/entries/en/${{ args.word | urlencode }}" - - - map: - word: "${{ item.word }}" - phonetic: "${{ (() => { if (item.phonetic) return item.phonetic; if (item.phonetics) { for (const p of item.phonetics) { if (p.text) return p.text; } } return ''; })() }}" - type: "${{ (() => { if (item.meanings && item.meanings[0] && item.meanings[0].partOfSpeech) return item.meanings[0].partOfSpeech; return 'N/A'; })() }}" - definition: "${{ (() => { if (item.meanings && item.meanings[0] && item.meanings[0].definitions && item.meanings[0].definitions[0] && item.meanings[0].definitions[0].definition) return item.meanings[0].definitions[0].definition; return 'No definition found in API.'; })() }}" - - - limit: 1 - -columns: [word, phonetic, type, definition] diff --git a/clis/dictionary/synonyms.ts b/clis/dictionary/synonyms.ts new file mode 100644 index 00000000..2a471a41 --- /dev/null +++ b/clis/dictionary/synonyms.ts @@ -0,0 +1,28 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'dictionary', + name: 'synonyms', + description: 'Find synonyms for a specific word', + domain: 'api.dictionaryapi.dev', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { + name: 'word', + type: 'string', + required: true, + positional: true, + help: 'Word to find synonyms for (e.g., serendipity)', + }, + ], + columns: ['word', 'synonyms'], + pipeline: [ + { fetch: { url: 'https://api.dictionaryapi.dev/api/v2/entries/en/${{ args.word | urlencode }}' } }, + { map: { + word: '${{ item.word }}', + synonyms: `\${{ (() => { const s = new Set(); if (item.meanings) { for (const m of item.meanings) { if (m.synonyms) { for (const syn of m.synonyms) s.add(syn); } if (m.definitions) { for (const d of m.definitions) { if (d.synonyms) { for (const syn of d.synonyms) s.add(syn); } } } } } const arr = Array.from(s); return arr.length > 0 ? arr.slice(0, 5).join(', ') : 'No synonyms found in API.'; })() }}`, + } }, + { limit: 1 }, + ], +}); diff --git a/clis/dictionary/synonyms.yaml b/clis/dictionary/synonyms.yaml deleted file mode 100644 index e719aa3d..00000000 --- a/clis/dictionary/synonyms.yaml +++ /dev/null @@ -1,25 +0,0 @@ -site: dictionary -name: synonyms -description: Find synonyms for a specific word -domain: api.dictionaryapi.dev -strategy: public -browser: false - -args: - word: - type: string - required: true - positional: true - description: Word to find synonyms for (e.g., serendipity) - -pipeline: - - fetch: - url: "https://api.dictionaryapi.dev/api/v2/entries/en/${{ args.word | urlencode }}" - - - map: - word: "${{ item.word }}" - synonyms: "${{ (() => { const s = new Set(); if (item.meanings) { for (const m of item.meanings) { if (m.synonyms) { for (const syn of m.synonyms) s.add(syn); } if (m.definitions) { for (const d of m.definitions) { if (d.synonyms) { for (const syn of d.synonyms) s.add(syn); } } } } } const arr = Array.from(s); return arr.length > 0 ? arr.slice(0, 5).join(', ') : 'No synonyms found in API.'; })() }}" - - - limit: 1 - -columns: [word, synonyms] diff --git a/clis/douban/subject.ts b/clis/douban/subject.ts new file mode 100644 index 00000000..c85e60ce --- /dev/null +++ b/clis/douban/subject.ts @@ -0,0 +1,119 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'douban', + name: 'subject', + description: '获取电影详情', + domain: 'movie.douban.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'id', required: true, positional: true, help: '电影 ID' }, + ], + columns: [ + 'id', + 'title', + 'originalTitle', + 'year', + 'rating', + 'ratingCount', + 'genres', + 'directors', + 'casts', + 'country', + 'duration', + 'summary', + 'url', + ], + pipeline: [ + { navigate: 'https://movie.douban.com/subject/${{ args.id }}' }, + { evaluate: `(async () => { + const id = '\${{ args.id }}'; + + // Wait for page to load + await new Promise(r => setTimeout(r, 2000)); + + // Extract title - v:itemreviewed contains "中文名 OriginalName" + const titleEl = document.querySelector('span[property="v:itemreviewed"]'); + const fullTitle = titleEl?.textContent?.trim() || ''; + + // Split title and originalTitle + // Douban format: "中文名 OriginalName" - split by first space that separates CJK from non-CJK + let title = fullTitle; + let originalTitle = ''; + const titleMatch = fullTitle.match(/^([\\u4e00-\\u9fff\\u3000-\\u303f\\uff00-\\uffef]+(?:\\s*[\\u4e00-\\u9fff\\u3000-\\u303f\\uff00-\\uffef·::!?]+)*)\\s+(.+)$/); + if (titleMatch) { + title = titleMatch[1].trim(); + originalTitle = titleMatch[2].trim(); + } + + // Extract year + const yearEl = document.querySelector('.year'); + const year = yearEl?.textContent?.trim().replace(/[()()]/g, '') || ''; + + // Extract rating + const ratingEl = document.querySelector('strong[property="v:average"]'); + const rating = parseFloat(ratingEl?.textContent || '0'); + + // Extract rating count + const ratingCountEl = document.querySelector('span[property="v:votes"]'); + const ratingCount = parseInt(ratingCountEl?.textContent || '0', 10); + + // Extract genres + const genreEls = document.querySelectorAll('span[property="v:genre"]'); + const genres = Array.from(genreEls).map(el => el.textContent?.trim()).filter(Boolean).join(','); + + // Extract directors + const directorEls = document.querySelectorAll('a[rel="v:directedBy"]'); + const directors = Array.from(directorEls).map(el => el.textContent?.trim()).filter(Boolean).join(','); + + // Extract casts + const castEls = document.querySelectorAll('a[rel="v:starring"]'); + const casts = Array.from(castEls).slice(0, 5).map(el => el.textContent?.trim()).filter(Boolean); + + // Extract info section for country and duration + const infoEl = document.querySelector('#info'); + const infoText = infoEl?.textContent || ''; + + // Extract country/region from #info as list + let country = []; + const countryMatch = infoText.match(/制片国家\\/地区:\\s*([^\\n]+)/); + if (countryMatch) { + country = countryMatch[1].trim().split(/\\s*\\/\\s*/).filter(Boolean); + } + + // Extract duration from #info as pure number in min + const durationEl = document.querySelector('span[property="v:runtime"]'); + let durationRaw = durationEl?.textContent?.trim() || ''; + if (!durationRaw) { + const durationMatch = infoText.match(/片长:\\s*([^\\n]+)/); + if (durationMatch) { + durationRaw = durationMatch[1].trim(); + } + } + const durationNumMatch = durationRaw.match(/(\\d+)/); + const duration = durationNumMatch ? parseInt(durationNumMatch[1], 10) : null; + + // Extract summary + const summaryEl = document.querySelector('span[property="v:summary"]'); + const summary = summaryEl?.textContent?.trim() || ''; + + return [{ + id, + title, + originalTitle, + year, + rating, + ratingCount, + genres, + directors, + casts, + country, + duration, + summary: summary.substring(0, 200), + url: \`https://movie.douban.com/subject/\${id}\` + }]; +})() +` }, + ], +}); diff --git a/clis/douban/subject.yaml b/clis/douban/subject.yaml deleted file mode 100644 index 043602df..00000000 --- a/clis/douban/subject.yaml +++ /dev/null @@ -1,107 +0,0 @@ -site: douban -name: subject -description: 获取电影详情 -domain: movie.douban.com -strategy: cookie -browser: true - -args: - id: - positional: true - required: true - type: str - description: 电影 ID - -pipeline: - - navigate: https://movie.douban.com/subject/${{ args.id }} - - - evaluate: | - (async () => { - const id = '${{ args.id }}'; - - // Wait for page to load - await new Promise(r => setTimeout(r, 2000)); - - // Extract title - v:itemreviewed contains "中文名 OriginalName" - const titleEl = document.querySelector('span[property="v:itemreviewed"]'); - const fullTitle = titleEl?.textContent?.trim() || ''; - - // Split title and originalTitle - // Douban format: "中文名 OriginalName" - split by first space that separates CJK from non-CJK - let title = fullTitle; - let originalTitle = ''; - const titleMatch = fullTitle.match(/^([\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]+(?:\s*[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef·::!?]+)*)\s+(.+)$/); - if (titleMatch) { - title = titleMatch[1].trim(); - originalTitle = titleMatch[2].trim(); - } - - // Extract year - const yearEl = document.querySelector('.year'); - const year = yearEl?.textContent?.trim().replace(/[()()]/g, '') || ''; - - // Extract rating - const ratingEl = document.querySelector('strong[property="v:average"]'); - const rating = parseFloat(ratingEl?.textContent || '0'); - - // Extract rating count - const ratingCountEl = document.querySelector('span[property="v:votes"]'); - const ratingCount = parseInt(ratingCountEl?.textContent || '0', 10); - - // Extract genres - const genreEls = document.querySelectorAll('span[property="v:genre"]'); - const genres = Array.from(genreEls).map(el => el.textContent?.trim()).filter(Boolean).join(','); - - // Extract directors - const directorEls = document.querySelectorAll('a[rel="v:directedBy"]'); - const directors = Array.from(directorEls).map(el => el.textContent?.trim()).filter(Boolean).join(','); - - // Extract casts - const castEls = document.querySelectorAll('a[rel="v:starring"]'); - const casts = Array.from(castEls).slice(0, 5).map(el => el.textContent?.trim()).filter(Boolean); - - // Extract info section for country and duration - const infoEl = document.querySelector('#info'); - const infoText = infoEl?.textContent || ''; - - // Extract country/region from #info as list - let country = []; - const countryMatch = infoText.match(/制片国家\/地区:\s*([^\n]+)/); - if (countryMatch) { - country = countryMatch[1].trim().split(/\s*\/\s*/).filter(Boolean); - } - - // Extract duration from #info as pure number in min - const durationEl = document.querySelector('span[property="v:runtime"]'); - let durationRaw = durationEl?.textContent?.trim() || ''; - if (!durationRaw) { - const durationMatch = infoText.match(/片长:\s*([^\n]+)/); - if (durationMatch) { - durationRaw = durationMatch[1].trim(); - } - } - const durationNumMatch = durationRaw.match(/(\d+)/); - const duration = durationNumMatch ? parseInt(durationNumMatch[1], 10) : null; - - // Extract summary - const summaryEl = document.querySelector('span[property="v:summary"]'); - const summary = summaryEl?.textContent?.trim() || ''; - - return [{ - id, - title, - originalTitle, - year, - rating, - ratingCount, - genres, - directors, - casts, - country, - duration, - summary: summary.substring(0, 200), - url: `https://movie.douban.com/subject/${id}` - }]; - })() - -columns: [id, title, originalTitle, year, rating, ratingCount, genres, directors, casts, country, duration, summary, url] diff --git a/clis/douban/top250.ts b/clis/douban/top250.ts new file mode 100644 index 00000000..b391dc6d --- /dev/null +++ b/clis/douban/top250.ts @@ -0,0 +1,68 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'douban', + name: 'top250', + description: '豆瓣电影 Top250', + domain: 'movie.douban.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'limit', type: 'int', default: 250, help: '返回结果数量' }, + ], + columns: ['rank', 'id', 'title', 'rating', 'url'], + pipeline: [ + { navigate: 'https://movie.douban.com/top250' }, + { evaluate: `async () => { + const results = []; + const limit = \${{ args.limit }}; + + const parsePage = (doc) => { + const items = doc.querySelectorAll('.item'); + for (const item of items) { + if (results.length >= limit) break; + + const rankEl = item.querySelector('.pic em'); + const linkEl = item.querySelector('a'); + const titleEl = item.querySelector('.title'); + const ratingEl = item.querySelector('.rating_num'); + + const href = linkEl?.href || ''; + const matchResult = href.match(/subject\\/(\\d+)/); + const id = matchResult ? matchResult[1] : ''; + + const title = titleEl?.textContent?.trim() || ''; + const rank = parseInt(rankEl?.textContent || '0', 10); + const rating = ratingEl?.textContent?.trim() || ''; + + if (id && title) { + results.push({ + rank: rank || results.length + 1, + id, + title, + rating: rating ? parseFloat(rating) : 0, + url: href + }); + } + } + }; + + parsePage(document); + + for (let start = 25; start < 250 && results.length < limit; start += 25) { + const resp = await fetch(\`https://movie.douban.com/top250?start=\${start}\`); + if (!resp.ok) break; + const html = await resp.text(); + if (!html) break; + + const doc = new DOMParser().parseFromString(html, 'text/html'); + parsePage(doc); + await new Promise(r => setTimeout(r, 150)); + } + + return results; +} +` }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/douban/top250.yaml b/clis/douban/top250.yaml deleted file mode 100644 index 902540e5..00000000 --- a/clis/douban/top250.yaml +++ /dev/null @@ -1,70 +0,0 @@ -site: douban -name: top250 -description: 豆瓣电影 Top250 -domain: movie.douban.com -strategy: cookie -browser: true - -args: - limit: - type: int - default: 250 - description: 返回结果数量 - -pipeline: - - navigate: https://movie.douban.com/top250 - - - evaluate: | - async () => { - const results = []; - const limit = ${{ args.limit }}; - - const parsePage = (doc) => { - const items = doc.querySelectorAll('.item'); - for (const item of items) { - if (results.length >= limit) break; - - const rankEl = item.querySelector('.pic em'); - const linkEl = item.querySelector('a'); - const titleEl = item.querySelector('.title'); - const ratingEl = item.querySelector('.rating_num'); - - const href = linkEl?.href || ''; - const matchResult = href.match(/subject\/(\d+)/); - const id = matchResult ? matchResult[1] : ''; - - const title = titleEl?.textContent?.trim() || ''; - const rank = parseInt(rankEl?.textContent || '0', 10); - const rating = ratingEl?.textContent?.trim() || ''; - - if (id && title) { - results.push({ - rank: rank || results.length + 1, - id, - title, - rating: rating ? parseFloat(rating) : 0, - url: href - }); - } - } - }; - - parsePage(document); - - for (let start = 25; start < 250 && results.length < limit; start += 25) { - const resp = await fetch(`https://movie.douban.com/top250?start=${start}`); - if (!resp.ok) break; - const html = await resp.text(); - if (!html) break; - - const doc = new DOMParser().parseFromString(html, 'text/html'); - parsePage(doc); - await new Promise(r => setTimeout(r, 150)); - } - - return results; - } - - - limit: ${{ args.limit }} - -columns: [rank, id, title, rating, url] diff --git a/clis/facebook/add-friend.ts b/clis/facebook/add-friend.ts new file mode 100644 index 00000000..5bc13ef3 --- /dev/null +++ b/clis/facebook/add-friend.ts @@ -0,0 +1,44 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'facebook', + name: 'add-friend', + description: 'Send a friend request on Facebook', + domain: 'www.facebook.com', + args: [ + { + name: 'username', + required: true, + positional: true, + help: 'Facebook username or profile URL', + }, + ], + columns: ['status', 'username'], + pipeline: [ + { navigate: { url: 'https://www.facebook.com/${{ args.username }}', settleMs: 3000 } }, + { evaluate: `(async () => { + const username = \${{ args.username | json }}; + // Find "Add Friend" button + const buttons = Array.from(document.querySelectorAll('[role="button"]')); + const addBtn = buttons.find(b => { + const text = b.textContent.trim(); + return text === '加好友' || text === 'Add Friend' || text === 'Add friend'; + }); + + if (!addBtn) { + // Check if already friends + const isFriend = buttons.some(b => { + const t = b.textContent.trim(); + return t === '好友' || t === 'Friends' || t.includes('已发送') || t.includes('Pending'); + }); + if (isFriend) return [{ status: 'Already friends or request pending', username }]; + return [{ status: 'Add Friend button not found', username }]; + } + + addBtn.click(); + await new Promise(r => setTimeout(r, 1500)); + return [{ status: 'Friend request sent', username }]; +})() +` }, + ], +}); diff --git a/clis/facebook/add-friend.yaml b/clis/facebook/add-friend.yaml deleted file mode 100644 index 5939a49b..00000000 --- a/clis/facebook/add-friend.yaml +++ /dev/null @@ -1,43 +0,0 @@ -site: facebook -name: add-friend -description: Send a friend request on Facebook -domain: www.facebook.com - -args: - username: - type: str - required: true - positional: true - description: Facebook username or profile URL - -pipeline: - - navigate: - url: https://www.facebook.com/${{ args.username }} - settleMs: 3000 - - - evaluate: | - (async () => { - const username = ${{ args.username | json }}; - // Find "Add Friend" button - const buttons = Array.from(document.querySelectorAll('[role="button"]')); - const addBtn = buttons.find(b => { - const text = b.textContent.trim(); - return text === '加好友' || text === 'Add Friend' || text === 'Add friend'; - }); - - if (!addBtn) { - // Check if already friends - const isFriend = buttons.some(b => { - const t = b.textContent.trim(); - return t === '好友' || t === 'Friends' || t.includes('已发送') || t.includes('Pending'); - }); - if (isFriend) return [{ status: 'Already friends or request pending', username }]; - return [{ status: 'Add Friend button not found', username }]; - } - - addBtn.click(); - await new Promise(r => setTimeout(r, 1500)); - return [{ status: 'Friend request sent', username }]; - })() - -columns: [status, username] diff --git a/clis/facebook/events.ts b/clis/facebook/events.ts new file mode 100644 index 00000000..50072c51 --- /dev/null +++ b/clis/facebook/events.ts @@ -0,0 +1,41 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'facebook', + name: 'events', + description: 'Browse Facebook event categories', + domain: 'www.facebook.com', + args: [ + { name: 'limit', type: 'int', default: 15, help: 'Number of categories' }, + ], + columns: ['index', 'name'], + pipeline: [ + { navigate: { url: 'https://www.facebook.com/events', settleMs: 3000 } }, + { evaluate: `(() => { + const limit = \${{ args.limit }}; + // Try actual event items first + const articles = document.querySelectorAll('[role="article"]'); + if (articles.length > 0) { + return Array.from(articles).slice(0, limit).map((el, i) => ({ + index: i + 1, + name: el.textContent.trim().replace(/\\s+/g, ' ').substring(0, 120), + })); + } + + // List event categories from sidebar navigation + const links = Array.from(document.querySelectorAll('[role="navigation"] a')) + .filter(a => { + const href = a.href || ''; + const text = a.textContent.trim(); + return href.includes('/events/') && text.length > 1 && text.length < 60 && + !href.includes('create'); + }); + + return links.slice(0, limit).map((a, i) => ({ + index: i + 1, + name: a.textContent.trim(), + })); +})() +` }, + ], +}); diff --git a/clis/facebook/events.yaml b/clis/facebook/events.yaml deleted file mode 100644 index a175881a..00000000 --- a/clis/facebook/events.yaml +++ /dev/null @@ -1,44 +0,0 @@ -site: facebook -name: events -description: Browse Facebook event categories -domain: www.facebook.com - -args: - limit: - type: int - default: 15 - description: Number of categories - -pipeline: - - navigate: - url: https://www.facebook.com/events - settleMs: 3000 - - - evaluate: | - (() => { - const limit = ${{ args.limit }}; - // Try actual event items first - const articles = document.querySelectorAll('[role="article"]'); - if (articles.length > 0) { - return Array.from(articles).slice(0, limit).map((el, i) => ({ - index: i + 1, - name: el.textContent.trim().replace(/\s+/g, ' ').substring(0, 120), - })); - } - - // List event categories from sidebar navigation - const links = Array.from(document.querySelectorAll('[role="navigation"] a')) - .filter(a => { - const href = a.href || ''; - const text = a.textContent.trim(); - return href.includes('/events/') && text.length > 1 && text.length < 60 && - !href.includes('create'); - }); - - return links.slice(0, limit).map((a, i) => ({ - index: i + 1, - name: a.textContent.trim(), - })); - })() - -columns: [index, name] diff --git a/clis/facebook/feed.ts b/clis/facebook/feed.ts new file mode 100644 index 00000000..57b4d2ec --- /dev/null +++ b/clis/facebook/feed.ts @@ -0,0 +1,60 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'facebook', + name: 'feed', + description: 'Get your Facebook news feed', + domain: 'www.facebook.com', + args: [ + { name: 'limit', type: 'int', default: 10, help: 'Number of posts' }, + ], + columns: ['index', 'author', 'content', 'likes', 'comments', 'shares'], + pipeline: [ + { navigate: { url: 'https://www.facebook.com/', settleMs: 4000 } }, + { evaluate: `(() => { + const limit = \${{ args.limit }}; + const posts = document.querySelectorAll('[role="article"]'); + return Array.from(posts) + .filter(el => { + const text = el.textContent.trim(); + // Filter out "People you may know" suggestions (both CN and EN) + return text.length > 30 && + !text.startsWith('可能认识') && + !text.startsWith('People you may know') && + !text.startsWith('People You May Know'); + }) + .slice(0, limit) + .map((el, i) => { + // Author from header link + const headerLink = el.querySelector('h2 a, h3 a, h4 a, strong a'); + const author = headerLink ? headerLink.textContent.trim() : ''; + + // Post text: grab visible spans, filter noise + const spans = Array.from(el.querySelectorAll('div[dir="auto"]')) + .map(s => s.textContent.trim()) + .filter(t => t.length > 10 && t.length < 500); + const content = spans.length > 0 ? spans[0] : ''; + + // Engagement: find like/comment/share counts (CN + EN) + const allText = el.textContent; + const likesMatch = allText.match(/所有心情:([\\d,.\\s]*[\\d万亿KMk]+)/) || + allText.match(/All:\\s*([\\d,.KMk]+)/) || + allText.match(/([\\d,.KMk]+)\\s*(?:likes?|reactions?)/i); + const commentsMatch = allText.match(/([\\d,.]+\\s*[万亿]?)\\s*条评论/) || + allText.match(/([\\d,.KMk]+)\\s*comments?/i); + const sharesMatch = allText.match(/([\\d,.]+\\s*[万亿]?)\\s*次分享/) || + allText.match(/([\\d,.KMk]+)\\s*shares?/i); + + return { + index: i + 1, + author: author.substring(0, 50), + content: content.replace(/\\n/g, ' ').substring(0, 120), + likes: likesMatch ? likesMatch[1] : '-', + comments: commentsMatch ? commentsMatch[1] : '-', + shares: sharesMatch ? sharesMatch[1] : '-', + }; + }); +})() +` }, + ], +}); diff --git a/clis/facebook/feed.yaml b/clis/facebook/feed.yaml deleted file mode 100644 index 83992e74..00000000 --- a/clis/facebook/feed.yaml +++ /dev/null @@ -1,63 +0,0 @@ -site: facebook -name: feed -description: Get your Facebook news feed -domain: www.facebook.com - -args: - limit: - type: int - default: 10 - description: Number of posts - -pipeline: - - navigate: - url: https://www.facebook.com/ - settleMs: 4000 - - - evaluate: | - (() => { - const limit = ${{ args.limit }}; - const posts = document.querySelectorAll('[role="article"]'); - return Array.from(posts) - .filter(el => { - const text = el.textContent.trim(); - // Filter out "People you may know" suggestions (both CN and EN) - return text.length > 30 && - !text.startsWith('可能认识') && - !text.startsWith('People you may know') && - !text.startsWith('People You May Know'); - }) - .slice(0, limit) - .map((el, i) => { - // Author from header link - const headerLink = el.querySelector('h2 a, h3 a, h4 a, strong a'); - const author = headerLink ? headerLink.textContent.trim() : ''; - - // Post text: grab visible spans, filter noise - const spans = Array.from(el.querySelectorAll('div[dir="auto"]')) - .map(s => s.textContent.trim()) - .filter(t => t.length > 10 && t.length < 500); - const content = spans.length > 0 ? spans[0] : ''; - - // Engagement: find like/comment/share counts (CN + EN) - const allText = el.textContent; - const likesMatch = allText.match(/所有心情:([\d,.\s]*[\d万亿KMk]+)/) || - allText.match(/All:\s*([\d,.KMk]+)/) || - allText.match(/([\d,.KMk]+)\s*(?:likes?|reactions?)/i); - const commentsMatch = allText.match(/([\d,.]+\s*[万亿]?)\s*条评论/) || - allText.match(/([\d,.KMk]+)\s*comments?/i); - const sharesMatch = allText.match(/([\d,.]+\s*[万亿]?)\s*次分享/) || - allText.match(/([\d,.KMk]+)\s*shares?/i); - - return { - index: i + 1, - author: author.substring(0, 50), - content: content.replace(/\n/g, ' ').substring(0, 120), - likes: likesMatch ? likesMatch[1] : '-', - comments: commentsMatch ? commentsMatch[1] : '-', - shares: sharesMatch ? sharesMatch[1] : '-', - }; - }); - })() - -columns: [index, author, content, likes, comments, shares] diff --git a/clis/facebook/friends.ts b/clis/facebook/friends.ts new file mode 100644 index 00000000..e36ea47c --- /dev/null +++ b/clis/facebook/friends.ts @@ -0,0 +1,39 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'facebook', + name: 'friends', + description: 'Get Facebook friend suggestions', + domain: 'www.facebook.com', + args: [ + { name: 'limit', type: 'int', default: 10, help: 'Number of friend suggestions' }, + ], + columns: ['index', 'name', 'mutual'], + pipeline: [ + { navigate: { url: 'https://www.facebook.com/friends', settleMs: 3000 } }, + { evaluate: `(() => { + const limit = \${{ args.limit }}; + const items = document.querySelectorAll('[role="listitem"]'); + return Array.from(items) + .slice(0, limit) + .map((el, i) => { + const text = el.textContent.trim().replace(/\\s+/g, ' '); + // Extract mutual info if present (before name extraction to avoid pollution) + const mutualMatch = text.match(/([\\d,]+)\\s*位.*(?:关注|共同|mutual)/); + // Extract name: remove mutual info, action buttons, etc. + let name = text + .replace(/[\\d,]+\\s*位.*(?:关注了|共同好友|mutual friends?)/, '') + .replace(/加好友.*/, '').replace(/Add [Ff]riend.*/, '') + .replace(/移除$/, '').replace(/Remove$/, '') + .trim(); + return { + index: i + 1, + name: name.substring(0, 50), + mutual: mutualMatch ? mutualMatch[1] : '-', + }; + }) + .filter(item => item.name.length > 0); +})() +` }, + ], +}); diff --git a/clis/facebook/friends.yaml b/clis/facebook/friends.yaml deleted file mode 100644 index 0d8b25cb..00000000 --- a/clis/facebook/friends.yaml +++ /dev/null @@ -1,42 +0,0 @@ -site: facebook -name: friends -description: Get Facebook friend suggestions -domain: www.facebook.com - -args: - limit: - type: int - default: 10 - description: Number of friend suggestions - -pipeline: - - navigate: - url: https://www.facebook.com/friends - settleMs: 3000 - - - evaluate: | - (() => { - const limit = ${{ args.limit }}; - const items = document.querySelectorAll('[role="listitem"]'); - return Array.from(items) - .slice(0, limit) - .map((el, i) => { - const text = el.textContent.trim().replace(/\s+/g, ' '); - // Extract mutual info if present (before name extraction to avoid pollution) - const mutualMatch = text.match(/([\d,]+)\s*位.*(?:关注|共同|mutual)/); - // Extract name: remove mutual info, action buttons, etc. - let name = text - .replace(/[\d,]+\s*位.*(?:关注了|共同好友|mutual friends?)/, '') - .replace(/加好友.*/, '').replace(/Add [Ff]riend.*/, '') - .replace(/移除$/, '').replace(/Remove$/, '') - .trim(); - return { - index: i + 1, - name: name.substring(0, 50), - mutual: mutualMatch ? mutualMatch[1] : '-', - }; - }) - .filter(item => item.name.length > 0); - })() - -columns: [index, name, mutual] diff --git a/clis/facebook/groups.ts b/clis/facebook/groups.ts new file mode 100644 index 00000000..506b2652 --- /dev/null +++ b/clis/facebook/groups.ts @@ -0,0 +1,47 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'facebook', + name: 'groups', + description: 'List your Facebook groups', + domain: 'www.facebook.com', + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of groups' }, + ], + columns: ['index', 'name', 'last_post', 'url'], + pipeline: [ + { navigate: { url: 'https://www.facebook.com/groups/feed/', settleMs: 3000 } }, + { evaluate: `(() => { + const limit = \${{ args.limit }}; + const links = Array.from(document.querySelectorAll('a')) + .filter(a => { + const href = a.href || ''; + return href.includes('/groups/') && + !href.includes('/feed') && + !href.includes('/discover') && + !href.includes('/joins') && + !href.includes('category=create') && + a.textContent.trim().length > 2; + }); + + // Deduplicate by href + const seen = new Set(); + const groups = []; + for (const a of links) { + const href = a.href.split('?')[0]; + if (seen.has(href)) continue; + seen.add(href); + const raw = a.textContent.trim().replace(/\\s+/g, ' '); + // Split name from "上次发帖" info + const parts = raw.split(/上次发帖|Last post/); + groups.push({ + name: (parts[0] || '').trim().substring(0, 60), + last_post: parts[1] ? parts[1].replace(/^[::]/, '').trim() : '-', + url: href, + }); + } + return groups.slice(0, limit).map((g, i) => ({ index: i + 1, ...g })); +})() +` }, + ], +}); diff --git a/clis/facebook/groups.yaml b/clis/facebook/groups.yaml deleted file mode 100644 index c31d956b..00000000 --- a/clis/facebook/groups.yaml +++ /dev/null @@ -1,50 +0,0 @@ -site: facebook -name: groups -description: List your Facebook groups -domain: www.facebook.com - -args: - limit: - type: int - default: 20 - description: Number of groups - -pipeline: - - navigate: - url: https://www.facebook.com/groups/feed/ - settleMs: 3000 - - - evaluate: | - (() => { - const limit = ${{ args.limit }}; - const links = Array.from(document.querySelectorAll('a')) - .filter(a => { - const href = a.href || ''; - return href.includes('/groups/') && - !href.includes('/feed') && - !href.includes('/discover') && - !href.includes('/joins') && - !href.includes('category=create') && - a.textContent.trim().length > 2; - }); - - // Deduplicate by href - const seen = new Set(); - const groups = []; - for (const a of links) { - const href = a.href.split('?')[0]; - if (seen.has(href)) continue; - seen.add(href); - const raw = a.textContent.trim().replace(/\s+/g, ' '); - // Split name from "上次发帖" info - const parts = raw.split(/上次发帖|Last post/); - groups.push({ - name: (parts[0] || '').trim().substring(0, 60), - last_post: parts[1] ? parts[1].replace(/^[::]/, '').trim() : '-', - url: href, - }); - } - return groups.slice(0, limit).map((g, i) => ({ index: i + 1, ...g })); - })() - -columns: [index, name, last_post, url] diff --git a/clis/facebook/join-group.ts b/clis/facebook/join-group.ts new file mode 100644 index 00000000..3f21abec --- /dev/null +++ b/clis/facebook/join-group.ts @@ -0,0 +1,45 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'facebook', + name: 'join-group', + description: 'Join a Facebook group', + domain: 'www.facebook.com', + args: [ + { + name: 'group', + required: true, + positional: true, + help: `Group ID or URL path (e.g. '1876150192925481' or group name)`, + }, + ], + columns: ['status', 'group'], + pipeline: [ + { navigate: { url: 'https://www.facebook.com/groups/${{ args.group }}', settleMs: 3000 } }, + { evaluate: `(async () => { + const group = \${{ args.group | json }}; + const groupName = document.querySelector('h1')?.textContent?.trim() || group; + + // Find "Join Group" button + const buttons = Array.from(document.querySelectorAll('[role="button"]')); + const joinBtn = buttons.find(b => { + const text = b.textContent.trim(); + return text === '加入小组' || text === 'Join group' || text === 'Join Group'; + }); + + if (!joinBtn) { + const isMember = buttons.some(b => { + const t = b.textContent.trim(); + return t === '已加入' || t === 'Joined' || t === '成员' || t === 'Member'; + }); + if (isMember) return [{ status: 'Already a member', group: groupName }]; + return [{ status: 'Join button not found', group: groupName }]; + } + + joinBtn.click(); + await new Promise(r => setTimeout(r, 1500)); + return [{ status: 'Join request sent', group: groupName }]; +})() +` }, + ], +}); diff --git a/clis/facebook/join-group.yaml b/clis/facebook/join-group.yaml deleted file mode 100644 index 455bd163..00000000 --- a/clis/facebook/join-group.yaml +++ /dev/null @@ -1,44 +0,0 @@ -site: facebook -name: join-group -description: Join a Facebook group -domain: www.facebook.com - -args: - group: - type: str - required: true - positional: true - description: Group ID or URL path (e.g. '1876150192925481' or group name) - -pipeline: - - navigate: - url: https://www.facebook.com/groups/${{ args.group }} - settleMs: 3000 - - - evaluate: | - (async () => { - const group = ${{ args.group | json }}; - const groupName = document.querySelector('h1')?.textContent?.trim() || group; - - // Find "Join Group" button - const buttons = Array.from(document.querySelectorAll('[role="button"]')); - const joinBtn = buttons.find(b => { - const text = b.textContent.trim(); - return text === '加入小组' || text === 'Join group' || text === 'Join Group'; - }); - - if (!joinBtn) { - const isMember = buttons.some(b => { - const t = b.textContent.trim(); - return t === '已加入' || t === 'Joined' || t === '成员' || t === 'Member'; - }); - if (isMember) return [{ status: 'Already a member', group: groupName }]; - return [{ status: 'Join button not found', group: groupName }]; - } - - joinBtn.click(); - await new Promise(r => setTimeout(r, 1500)); - return [{ status: 'Join request sent', group: groupName }]; - })() - -columns: [status, group] diff --git a/clis/facebook/memories.ts b/clis/facebook/memories.ts new file mode 100644 index 00000000..995a96c9 --- /dev/null +++ b/clis/facebook/memories.ts @@ -0,0 +1,36 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'facebook', + name: 'memories', + description: 'Get your Facebook memories (On This Day)', + domain: 'www.facebook.com', + args: [ + { name: 'limit', type: 'int', default: 10, help: 'Number of memories' }, + ], + columns: ['index', 'source', 'content', 'time'], + pipeline: [ + { navigate: { url: 'https://www.facebook.com/onthisday', settleMs: 4000 } }, + { evaluate: `(() => { + const limit = \${{ args.limit }}; + const articles = document.querySelectorAll('[role="article"]'); + return Array.from(articles) + .slice(0, limit) + .map((el, i) => { + const headerLink = el.querySelector('h2 a, h3 a, h4 a, strong a'); + const spans = Array.from(el.querySelectorAll('div[dir="auto"]')) + .map(s => s.textContent.trim()) + .filter(t => t.length > 5 && t.length < 500); + const timeEl = el.querySelector('a[href*="/posts/"] span, a[href*="story_fbid"] span'); + return { + index: i + 1, + source: headerLink ? headerLink.textContent.trim().substring(0, 50) : '-', + content: (spans[0] || '').replace(/\\n/g, ' ').substring(0, 150), + time: timeEl ? timeEl.textContent.trim() : '-', + }; + }) + .filter(item => item.content.length > 0 || item.source !== '-'); +})() +` }, + ], +}); diff --git a/clis/facebook/memories.yaml b/clis/facebook/memories.yaml deleted file mode 100644 index 456bbf0e..00000000 --- a/clis/facebook/memories.yaml +++ /dev/null @@ -1,39 +0,0 @@ -site: facebook -name: memories -description: Get your Facebook memories (On This Day) -domain: www.facebook.com - -args: - limit: - type: int - default: 10 - description: Number of memories - -pipeline: - - navigate: - url: https://www.facebook.com/onthisday - settleMs: 4000 - - - evaluate: | - (() => { - const limit = ${{ args.limit }}; - const articles = document.querySelectorAll('[role="article"]'); - return Array.from(articles) - .slice(0, limit) - .map((el, i) => { - const headerLink = el.querySelector('h2 a, h3 a, h4 a, strong a'); - const spans = Array.from(el.querySelectorAll('div[dir="auto"]')) - .map(s => s.textContent.trim()) - .filter(t => t.length > 5 && t.length < 500); - const timeEl = el.querySelector('a[href*="/posts/"] span, a[href*="story_fbid"] span'); - return { - index: i + 1, - source: headerLink ? headerLink.textContent.trim().substring(0, 50) : '-', - content: (spans[0] || '').replace(/\n/g, ' ').substring(0, 150), - time: timeEl ? timeEl.textContent.trim() : '-', - }; - }) - .filter(item => item.content.length > 0 || item.source !== '-'); - })() - -columns: [index, source, content, time] diff --git a/clis/facebook/notifications.ts b/clis/facebook/notifications.ts new file mode 100644 index 00000000..8e49eb45 --- /dev/null +++ b/clis/facebook/notifications.ts @@ -0,0 +1,37 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'facebook', + name: 'notifications', + description: 'Get recent Facebook notifications', + domain: 'www.facebook.com', + args: [ + { name: 'limit', type: 'int', default: 15, help: 'Number of notifications' }, + ], + columns: ['index', 'text', 'time'], + pipeline: [ + { navigate: { url: 'https://www.facebook.com/notifications', settleMs: 3000 } }, + { evaluate: `(() => { + const limit = \${{ args.limit }}; + const items = document.querySelectorAll('[role="listitem"]'); + return Array.from(items) + .filter(el => el.querySelectorAll('a').length > 0) + .slice(0, limit) + .map((el, i) => { + const raw = el.textContent.trim().replace(/\\s+/g, ' '); + // Remove leading "未读" and trailing "标记为已读" + const cleaned = raw.replace(/^未读/, '').replace(/标记为已读$/, '').replace(/^Unread/, '').replace(/Mark as read$/, '').trim(); + // Try to extract time (last segment like "11小时", "5天", "1周") + const timeMatch = cleaned.match(/(\\d+\\s*(?:分钟|小时|天|周|个月|minutes?|hours?|days?|weeks?|months?))\\s*$/); + const time = timeMatch ? timeMatch[1] : ''; + const text = timeMatch ? cleaned.slice(0, -timeMatch[0].length).trim() : cleaned; + return { + index: i + 1, + text: text.substring(0, 150), + time: time || '-', + }; + }); +})() +` }, + ], +}); diff --git a/clis/facebook/notifications.yaml b/clis/facebook/notifications.yaml deleted file mode 100644 index 7853945e..00000000 --- a/clis/facebook/notifications.yaml +++ /dev/null @@ -1,40 +0,0 @@ -site: facebook -name: notifications -description: Get recent Facebook notifications -domain: www.facebook.com - -args: - limit: - type: int - default: 15 - description: Number of notifications - -pipeline: - - navigate: - url: https://www.facebook.com/notifications - settleMs: 3000 - - - evaluate: | - (() => { - const limit = ${{ args.limit }}; - const items = document.querySelectorAll('[role="listitem"]'); - return Array.from(items) - .filter(el => el.querySelectorAll('a').length > 0) - .slice(0, limit) - .map((el, i) => { - const raw = el.textContent.trim().replace(/\s+/g, ' '); - // Remove leading "未读" and trailing "标记为已读" - const cleaned = raw.replace(/^未读/, '').replace(/标记为已读$/, '').replace(/^Unread/, '').replace(/Mark as read$/, '').trim(); - // Try to extract time (last segment like "11小时", "5天", "1周") - const timeMatch = cleaned.match(/(\d+\s*(?:分钟|小时|天|周|个月|minutes?|hours?|days?|weeks?|months?))\s*$/); - const time = timeMatch ? timeMatch[1] : ''; - const text = timeMatch ? cleaned.slice(0, -timeMatch[0].length).trim() : cleaned; - return { - index: i + 1, - text: text.substring(0, 150), - time: time || '-', - }; - }); - })() - -columns: [index, text, time] diff --git a/clis/facebook/profile.ts b/clis/facebook/profile.ts new file mode 100644 index 00000000..cc999033 --- /dev/null +++ b/clis/facebook/profile.ts @@ -0,0 +1,38 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'facebook', + name: 'profile', + description: 'Get Facebook user/page profile info', + domain: 'www.facebook.com', + args: [ + { + name: 'username', + required: true, + positional: true, + help: 'Facebook username or page name', + }, + ], + columns: ['name', 'username', 'friends', 'followers', 'url'], + pipeline: [ + { navigate: { url: 'https://www.facebook.com/${{ args.username }}', settleMs: 3000 } }, + { evaluate: `(() => { + const h1 = document.querySelector('h1'); + let name = h1 ? h1.textContent.trim() : ''; + + // Find friends/followers links + const links = Array.from(document.querySelectorAll('a')); + const friendsLink = links.find(a => a.href && a.href.includes('/friends')); + const followersLink = links.find(a => a.href && a.href.includes('/followers')); + + return [{ + name: name, + username: \${{ args.username | json }}, + friends: friendsLink ? friendsLink.textContent.trim() : '-', + followers: followersLink ? followersLink.textContent.trim() : '-', + url: window.location.href, + }]; +})() +` }, + ], +}); diff --git a/clis/facebook/profile.yaml b/clis/facebook/profile.yaml deleted file mode 100644 index 5b5ff621..00000000 --- a/clis/facebook/profile.yaml +++ /dev/null @@ -1,37 +0,0 @@ -site: facebook -name: profile -description: Get Facebook user/page profile info -domain: www.facebook.com - -args: - username: - type: str - required: true - positional: true - description: Facebook username or page name - -pipeline: - - navigate: - url: https://www.facebook.com/${{ args.username }} - settleMs: 3000 - - - evaluate: | - (() => { - const h1 = document.querySelector('h1'); - let name = h1 ? h1.textContent.trim() : ''; - - // Find friends/followers links - const links = Array.from(document.querySelectorAll('a')); - const friendsLink = links.find(a => a.href && a.href.includes('/friends')); - const followersLink = links.find(a => a.href && a.href.includes('/followers')); - - return [{ - name: name, - username: ${{ args.username | json }}, - friends: friendsLink ? friendsLink.textContent.trim() : '-', - followers: followersLink ? followersLink.textContent.trim() : '-', - url: window.location.href, - }]; - })() - -columns: [name, username, friends, followers, url] diff --git a/clis/facebook/search.test.ts b/clis/facebook/search.test.ts index 476606ae..7ac3efd1 100644 --- a/clis/facebook/search.test.ts +++ b/clis/facebook/search.test.ts @@ -3,17 +3,13 @@ * Facebook search must navigate in the pipeline before DOM extraction. */ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import yaml from 'js-yaml'; import { describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; import { executePipeline } from '@jackwener/opencli/pipeline'; import type { IPage } from '@jackwener/opencli/types'; -interface YamlCommand { - pipeline?: Array>; -} +// Import the adapter to register it +import './search.js'; /** * Minimal browser mock for pipeline execution tests. @@ -46,11 +42,9 @@ function createMockPage(): IPage { describe('facebook search pipeline', () => { it('navigates to search results before extracting DOM data', async () => { - // Load the YAML adapter directly so the regression test covers the shipped command definition. - const filePath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'search.yaml'); - const raw = fs.readFileSync(filePath, 'utf8'); - const command = yaml.load(raw) as YamlCommand; - const pipeline = command.pipeline ?? []; + const cmd = getRegistry().get('facebook/search'); + expect(cmd).toBeDefined(); + const pipeline = cmd!.pipeline ?? []; const page = createMockPage(); await executePipeline(page, pipeline, { diff --git a/clis/facebook/search.ts b/clis/facebook/search.ts new file mode 100644 index 00000000..a9f3cbae --- /dev/null +++ b/clis/facebook/search.ts @@ -0,0 +1,39 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'facebook', + name: 'search', + description: 'Search Facebook for people, pages, or posts', + domain: 'www.facebook.com', + args: [ + { name: 'query', required: true, positional: true, help: 'Search query' }, + { name: 'limit', type: 'int', default: 10, help: 'Number of results' }, + ], + columns: ['index', 'title', 'text', 'url'], + pipeline: [ + { navigate: 'https://www.facebook.com' }, + { navigate: { url: 'https://www.facebook.com/search/top?q=${{ args.query | urlencode }}', settleMs: 4000 } }, + { evaluate: `(async () => { + const limit = \${{ args.limit }}; + // Search results are typically in role="article" or role="listitem" + let items = document.querySelectorAll('[role="article"]'); + if (items.length === 0) { + items = document.querySelectorAll('[role="listitem"]'); + } + return Array.from(items) + .filter(el => el.textContent.trim().length > 20) + .slice(0, limit) + .map((el, i) => { + const link = el.querySelector('a[href*="facebook.com/"]'); + const heading = el.querySelector('h2, h3, h4, strong'); + return { + index: i + 1, + title: heading ? heading.textContent.trim().substring(0, 80) : '', + text: el.textContent.trim().replace(/\\s+/g, ' ').substring(0, 150), + url: link ? link.href.split('?')[0] : '', + }; + }); +})() +` }, + ], +}); diff --git a/clis/facebook/search.yaml b/clis/facebook/search.yaml deleted file mode 100644 index 818c61e5..00000000 --- a/clis/facebook/search.yaml +++ /dev/null @@ -1,47 +0,0 @@ -site: facebook -name: search -description: Search Facebook for people, pages, or posts -domain: www.facebook.com - -args: - query: - type: str - required: true - positional: true - description: Search query - limit: - type: int - default: 10 - description: Number of results - -pipeline: - - navigate: https://www.facebook.com - - - navigate: - url: https://www.facebook.com/search/top?q=${{ args.query | urlencode }} - settleMs: 4000 - - - evaluate: | - (async () => { - const limit = ${{ args.limit }}; - // Search results are typically in role="article" or role="listitem" - let items = document.querySelectorAll('[role="article"]'); - if (items.length === 0) { - items = document.querySelectorAll('[role="listitem"]'); - } - return Array.from(items) - .filter(el => el.textContent.trim().length > 20) - .slice(0, limit) - .map((el, i) => { - const link = el.querySelector('a[href*="facebook.com/"]'); - const heading = el.querySelector('h2, h3, h4, strong'); - return { - index: i + 1, - title: heading ? heading.textContent.trim().substring(0, 80) : '', - text: el.textContent.trim().replace(/\s+/g, ' ').substring(0, 150), - url: link ? link.href.split('?')[0] : '', - }; - }); - })() - -columns: [index, title, text, url] diff --git a/clis/hackernews/ask.ts b/clis/hackernews/ask.ts new file mode 100644 index 00000000..32253cd0 --- /dev/null +++ b/clis/hackernews/ask.ts @@ -0,0 +1,30 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'hackernews', + name: 'ask', + description: 'Hacker News Ask HN posts', + domain: 'news.ycombinator.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of stories' }, + ], + columns: ['rank', 'title', 'score', 'author', 'comments'], + pipeline: [ + { fetch: { url: 'https://hacker-news.firebaseio.com/v0/askstories.json' } }, + { limit: '${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}' }, + { map: { id: '${{ item }}' } }, + { fetch: { url: 'https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json' } }, + { filter: 'item.title && !item.deleted && !item.dead' }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + score: '${{ item.score }}', + author: '${{ item.by }}', + comments: '${{ item.descendants }}', + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/hackernews/ask.yaml b/clis/hackernews/ask.yaml deleted file mode 100644 index 040e2deb..00000000 --- a/clis/hackernews/ask.yaml +++ /dev/null @@ -1,38 +0,0 @@ -site: hackernews -name: ask -description: Hacker News Ask HN posts -domain: news.ycombinator.com -strategy: public -browser: false - -args: - limit: - type: int - default: 20 - description: Number of stories - -pipeline: - - fetch: - url: https://hacker-news.firebaseio.com/v0/askstories.json - - - limit: "${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}" - - - map: - id: ${{ item }} - - - fetch: - url: https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json - - - filter: item.title && !item.deleted && !item.dead - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - score: ${{ item.score }} - author: ${{ item.by }} - comments: ${{ item.descendants }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [rank, title, score, author, comments] diff --git a/clis/hackernews/best.ts b/clis/hackernews/best.ts new file mode 100644 index 00000000..66e9dd9d --- /dev/null +++ b/clis/hackernews/best.ts @@ -0,0 +1,30 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'hackernews', + name: 'best', + description: 'Hacker News best stories', + domain: 'news.ycombinator.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of stories' }, + ], + columns: ['rank', 'title', 'score', 'author', 'comments'], + pipeline: [ + { fetch: { url: 'https://hacker-news.firebaseio.com/v0/beststories.json' } }, + { limit: '${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}' }, + { map: { id: '${{ item }}' } }, + { fetch: { url: 'https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json' } }, + { filter: 'item.title && !item.deleted && !item.dead' }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + score: '${{ item.score }}', + author: '${{ item.by }}', + comments: '${{ item.descendants }}', + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/hackernews/best.yaml b/clis/hackernews/best.yaml deleted file mode 100644 index fc1167c4..00000000 --- a/clis/hackernews/best.yaml +++ /dev/null @@ -1,38 +0,0 @@ -site: hackernews -name: best -description: Hacker News best stories -domain: news.ycombinator.com -strategy: public -browser: false - -args: - limit: - type: int - default: 20 - description: Number of stories - -pipeline: - - fetch: - url: https://hacker-news.firebaseio.com/v0/beststories.json - - - limit: "${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}" - - - map: - id: ${{ item }} - - - fetch: - url: https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json - - - filter: item.title && !item.deleted && !item.dead - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - score: ${{ item.score }} - author: ${{ item.by }} - comments: ${{ item.descendants }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [rank, title, score, author, comments] diff --git a/clis/hackernews/jobs.ts b/clis/hackernews/jobs.ts new file mode 100644 index 00000000..9e789288 --- /dev/null +++ b/clis/hackernews/jobs.ts @@ -0,0 +1,28 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'hackernews', + name: 'jobs', + description: 'Hacker News job postings', + domain: 'news.ycombinator.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of job postings' }, + ], + columns: ['rank', 'title', 'author', 'url'], + pipeline: [ + { fetch: { url: 'https://hacker-news.firebaseio.com/v0/jobstories.json' } }, + { limit: '${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}' }, + { map: { id: '${{ item }}' } }, + { fetch: { url: 'https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json' } }, + { filter: 'item.title && !item.deleted && !item.dead' }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + author: '${{ item.by }}', + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/hackernews/jobs.yaml b/clis/hackernews/jobs.yaml deleted file mode 100644 index 48b488bc..00000000 --- a/clis/hackernews/jobs.yaml +++ /dev/null @@ -1,36 +0,0 @@ -site: hackernews -name: jobs -description: Hacker News job postings -domain: news.ycombinator.com -strategy: public -browser: false - -args: - limit: - type: int - default: 20 - description: Number of job postings - -pipeline: - - fetch: - url: https://hacker-news.firebaseio.com/v0/jobstories.json - - - limit: "${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}" - - - map: - id: ${{ item }} - - - fetch: - url: https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json - - - filter: item.title && !item.deleted && !item.dead - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - author: ${{ item.by }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [rank, title, author, url] diff --git a/clis/hackernews/new.ts b/clis/hackernews/new.ts new file mode 100644 index 00000000..0243034d --- /dev/null +++ b/clis/hackernews/new.ts @@ -0,0 +1,30 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'hackernews', + name: 'new', + description: 'Hacker News newest stories', + domain: 'news.ycombinator.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of stories' }, + ], + columns: ['rank', 'title', 'score', 'author', 'comments'], + pipeline: [ + { fetch: { url: 'https://hacker-news.firebaseio.com/v0/newstories.json' } }, + { limit: '${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}' }, + { map: { id: '${{ item }}' } }, + { fetch: { url: 'https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json' } }, + { filter: 'item.title && !item.deleted && !item.dead' }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + score: '${{ item.score }}', + author: '${{ item.by }}', + comments: '${{ item.descendants }}', + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/hackernews/new.yaml b/clis/hackernews/new.yaml deleted file mode 100644 index 23c5fed4..00000000 --- a/clis/hackernews/new.yaml +++ /dev/null @@ -1,38 +0,0 @@ -site: hackernews -name: new -description: Hacker News newest stories -domain: news.ycombinator.com -strategy: public -browser: false - -args: - limit: - type: int - default: 20 - description: Number of stories - -pipeline: - - fetch: - url: https://hacker-news.firebaseio.com/v0/newstories.json - - - limit: "${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}" - - - map: - id: ${{ item }} - - - fetch: - url: https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json - - - filter: item.title && !item.deleted && !item.dead - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - score: ${{ item.score }} - author: ${{ item.by }} - comments: ${{ item.descendants }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [rank, title, score, author, comments] diff --git a/clis/hackernews/search.ts b/clis/hackernews/search.ts new file mode 100644 index 00000000..a17981e2 --- /dev/null +++ b/clis/hackernews/search.ts @@ -0,0 +1,37 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'hackernews', + name: 'search', + description: 'Search Hacker News stories', + domain: 'news.ycombinator.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'query', required: true, positional: true, help: 'Search query' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of results' }, + { + name: 'sort', + default: 'relevance', + help: 'Sort by relevance or date', + choices: ['relevance', 'date'], + }, + ], + columns: ['rank', 'title', 'score', 'author', 'comments', 'url'], + pipeline: [ + { fetch: { + url: `https://hn.algolia.com/api/v1/\${{ args.sort === 'date' ? 'search_by_date' : 'search' }}`, + params: { query: '${{ args.query }}', tags: 'story', hitsPerPage: '${{ args.limit }}' }, + } }, + { select: 'hits' }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + score: '${{ item.points }}', + author: '${{ item.author }}', + comments: '${{ item.num_comments }}', + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/hackernews/search.yaml b/clis/hackernews/search.yaml deleted file mode 100644 index 94c4229b..00000000 --- a/clis/hackernews/search.yaml +++ /dev/null @@ -1,44 +0,0 @@ -site: hackernews -name: search -description: Search Hacker News stories -domain: news.ycombinator.com -strategy: public -browser: false - -args: - query: - type: str - required: true - positional: true - description: Search query - limit: - type: int - default: 20 - description: Number of results - sort: - type: str - default: relevance - choices: [relevance, date] - description: Sort by relevance or date - -pipeline: - - fetch: - url: "https://hn.algolia.com/api/v1/${{ args.sort === 'date' ? 'search_by_date' : 'search' }}" - params: - query: ${{ args.query }} - tags: story - hitsPerPage: ${{ args.limit }} - - - select: hits - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - score: ${{ item.points }} - author: ${{ item.author }} - comments: ${{ item.num_comments }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [rank, title, score, author, comments, url] diff --git a/clis/hackernews/show.ts b/clis/hackernews/show.ts new file mode 100644 index 00000000..4ac9a936 --- /dev/null +++ b/clis/hackernews/show.ts @@ -0,0 +1,30 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'hackernews', + name: 'show', + description: 'Hacker News Show HN posts', + domain: 'news.ycombinator.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of stories' }, + ], + columns: ['rank', 'title', 'score', 'author', 'comments'], + pipeline: [ + { fetch: { url: 'https://hacker-news.firebaseio.com/v0/showstories.json' } }, + { limit: '${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}' }, + { map: { id: '${{ item }}' } }, + { fetch: { url: 'https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json' } }, + { filter: 'item.title && !item.deleted && !item.dead' }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + score: '${{ item.score }}', + author: '${{ item.by }}', + comments: '${{ item.descendants }}', + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/hackernews/show.yaml b/clis/hackernews/show.yaml deleted file mode 100644 index 7266298d..00000000 --- a/clis/hackernews/show.yaml +++ /dev/null @@ -1,38 +0,0 @@ -site: hackernews -name: show -description: Hacker News Show HN posts -domain: news.ycombinator.com -strategy: public -browser: false - -args: - limit: - type: int - default: 20 - description: Number of stories - -pipeline: - - fetch: - url: https://hacker-news.firebaseio.com/v0/showstories.json - - - limit: "${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}" - - - map: - id: ${{ item }} - - - fetch: - url: https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json - - - filter: item.title && !item.deleted && !item.dead - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - score: ${{ item.score }} - author: ${{ item.by }} - comments: ${{ item.descendants }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [rank, title, score, author, comments] diff --git a/clis/hackernews/top.ts b/clis/hackernews/top.ts new file mode 100644 index 00000000..ad02305b --- /dev/null +++ b/clis/hackernews/top.ts @@ -0,0 +1,30 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'hackernews', + name: 'top', + description: 'Hacker News top stories', + domain: 'news.ycombinator.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of stories' }, + ], + columns: ['rank', 'title', 'score', 'author', 'comments'], + pipeline: [ + { fetch: { url: 'https://hacker-news.firebaseio.com/v0/topstories.json' } }, + { limit: '${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}' }, + { map: { id: '${{ item }}' } }, + { fetch: { url: 'https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json' } }, + { filter: 'item.title && !item.deleted && !item.dead' }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + score: '${{ item.score }}', + author: '${{ item.by }}', + comments: '${{ item.descendants }}', + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/hackernews/top.yaml b/clis/hackernews/top.yaml deleted file mode 100644 index 59c6f17d..00000000 --- a/clis/hackernews/top.yaml +++ /dev/null @@ -1,38 +0,0 @@ -site: hackernews -name: top -description: Hacker News top stories -domain: news.ycombinator.com -strategy: public -browser: false - -args: - limit: - type: int - default: 20 - description: Number of stories - -pipeline: - - fetch: - url: https://hacker-news.firebaseio.com/v0/topstories.json - - - limit: "${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}" - - - map: - id: ${{ item }} - - - fetch: - url: https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json - - - filter: item.title && !item.deleted && !item.dead - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - score: ${{ item.score }} - author: ${{ item.by }} - comments: ${{ item.descendants }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [rank, title, score, author, comments] diff --git a/clis/hackernews/user.ts b/clis/hackernews/user.ts new file mode 100644 index 00000000..a4b43860 --- /dev/null +++ b/clis/hackernews/user.ts @@ -0,0 +1,23 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'hackernews', + name: 'user', + description: 'Hacker News user profile', + domain: 'news.ycombinator.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'username', required: true, positional: true, help: 'HN username' }, + ], + columns: ['username', 'karma', 'created', 'about'], + pipeline: [ + { fetch: { url: 'https://hacker-news.firebaseio.com/v0/user/${{ args.username }}.json' } }, + { map: { + username: '${{ item.id }}', + karma: '${{ item.karma }}', + created: `\${{ item.created ? new Date(item.created * 1000).toISOString().slice(0, 10) : '' }}`, + about: '${{ item.about }}', + } }, + ], +}); diff --git a/clis/hackernews/user.yaml b/clis/hackernews/user.yaml deleted file mode 100644 index 7c1628c6..00000000 --- a/clis/hackernews/user.yaml +++ /dev/null @@ -1,25 +0,0 @@ -site: hackernews -name: user -description: Hacker News user profile -domain: news.ycombinator.com -strategy: public -browser: false - -args: - username: - type: str - required: true - positional: true - description: HN username - -pipeline: - - fetch: - url: https://hacker-news.firebaseio.com/v0/user/${{ args.username }}.json - - - map: - username: ${{ item.id }} - karma: ${{ item.karma }} - created: "${{ item.created ? new Date(item.created * 1000).toISOString().slice(0, 10) : '' }}" - about: ${{ item.about }} - -columns: [username, karma, created, about] diff --git a/clis/hupu/hot.ts b/clis/hupu/hot.ts new file mode 100644 index 00000000..c24115ba --- /dev/null +++ b/clis/hupu/hot.ts @@ -0,0 +1,41 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'hupu', + name: 'hot', + description: '虎扑热门帖子', + domain: 'bbs.hupu.com', + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of hot posts' }, + ], + columns: ['rank', 'title', 'url'], + pipeline: [ + { navigate: 'https://bbs.hupu.com/' }, + { evaluate: `(async () => { + // 从HTML中提取帖子信息(适配新的HTML结构) + const html = document.documentElement.outerHTML; + const posts = []; + + // 匹配当前虎扑页面结构的正则表达式 + // 结构: 标题 + const regex = /]*href="\\/(\\d{9})\\.html"[^>]*>]*class="t-title"[^>]*>([^<]+)<\\/span><\\/a>/g; + let match; + + while ((match = regex.exec(html)) !== null && posts.length < \${{ args.limit }}) { + posts.push({ + tid: match[1], + title: match[2].trim() + }); + } + + return posts; +})() +` }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + url: 'https://bbs.hupu.com/${{ item.tid }}.html', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/hupu/hot.yaml b/clis/hupu/hot.yaml deleted file mode 100644 index 264688e0..00000000 --- a/clis/hupu/hot.yaml +++ /dev/null @@ -1,43 +0,0 @@ -site: hupu -name: hot -description: 虎扑热门帖子 -domain: bbs.hupu.com - -args: - limit: - type: int - default: 20 - description: Number of hot posts - -pipeline: - - navigate: https://bbs.hupu.com/ - - - evaluate: | - (async () => { - // 从HTML中提取帖子信息(适配新的HTML结构) - const html = document.documentElement.outerHTML; - const posts = []; - - // 匹配当前虎扑页面结构的正则表达式 - // 结构: 标题 - const regex = /]*href="\/(\d{9})\.html"[^>]*>]*class="t-title"[^>]*>([^<]+)<\/span><\/a>/g; - let match; - - while ((match = regex.exec(html)) !== null && posts.length < ${{ args.limit }}) { - posts.push({ - tid: match[1], - title: match[2].trim() - }); - } - - return posts; - })() - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - url: https://bbs.hupu.com/${{ item.tid }}.html - - - limit: ${{ args.limit }} - -columns: [rank, title, url] diff --git a/clis/instagram/comment.ts b/clis/instagram/comment.ts new file mode 100644 index 00000000..8374aa56 --- /dev/null +++ b/clis/instagram/comment.ts @@ -0,0 +1,48 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'instagram', + name: 'comment', + description: 'Comment on an Instagram post', + domain: 'www.instagram.com', + args: [ + { + name: 'username', + required: true, + positional: true, + help: 'Username of the post author', + }, + { name: 'text', required: true, positional: true, help: 'Comment text' }, + { name: 'index', type: 'int', default: 1, help: 'Post index (1 = most recent)' }, + ], + columns: ['status', 'user', 'text'], + pipeline: [ + { navigate: 'https://www.instagram.com' }, + { evaluate: `(async () => { + const username = \${{ args.username | json }}; + const commentText = \${{ args.text | json }}; + const idx = \${{ args.index }} - 1; + const headers = { 'X-IG-App-ID': '936619743392459' }; + const opts = { credentials: 'include', headers }; + + const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts); + if (!r1.ok) throw new Error('User not found: ' + username); + const userId = (await r1.json())?.data?.user?.id; + + const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts); + const posts = (await r2.json())?.items || []; + if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found'); + const pk = posts[idx].pk; + + const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || ''; + const r3 = await fetch('https://www.instagram.com/api/v1/web/comments/' + pk + '/add/', { + method: 'POST', credentials: 'include', + headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'comment_text=' + encodeURIComponent(commentText), + }); + if (!r3.ok) throw new Error('Failed to comment: HTTP ' + r3.status); + return [{ status: 'Commented', user: username, text: commentText }]; +})() +` }, + ], +}); diff --git a/clis/instagram/comment.yaml b/clis/instagram/comment.yaml deleted file mode 100644 index bab4d480..00000000 --- a/clis/instagram/comment.yaml +++ /dev/null @@ -1,52 +0,0 @@ -site: instagram -name: comment -description: Comment on an Instagram post -domain: www.instagram.com - -args: - username: - type: str - required: true - positional: true - description: Username of the post author - text: - positional: true - type: str - required: true - description: Comment text - index: - type: int - default: 1 - description: Post index (1 = most recent) - -pipeline: - - navigate: https://www.instagram.com - - - evaluate: | - (async () => { - const username = ${{ args.username | json }}; - const commentText = ${{ args.text | json }}; - const idx = ${{ args.index }} - 1; - const headers = { 'X-IG-App-ID': '936619743392459' }; - const opts = { credentials: 'include', headers }; - - const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts); - if (!r1.ok) throw new Error('User not found: ' + username); - const userId = (await r1.json())?.data?.user?.id; - - const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts); - const posts = (await r2.json())?.items || []; - if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found'); - const pk = posts[idx].pk; - - const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || ''; - const r3 = await fetch('https://www.instagram.com/api/v1/web/comments/' + pk + '/add/', { - method: 'POST', credentials: 'include', - headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' }, - body: 'comment_text=' + encodeURIComponent(commentText), - }); - if (!r3.ok) throw new Error('Failed to comment: HTTP ' + r3.status); - return [{ status: 'Commented', user: username, text: commentText }]; - })() - -columns: [status, user, text] diff --git a/clis/instagram/explore.ts b/clis/instagram/explore.ts new file mode 100644 index 00000000..904e56ac --- /dev/null +++ b/clis/instagram/explore.ts @@ -0,0 +1,42 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'instagram', + name: 'explore', + description: 'Instagram explore/discover trending posts', + domain: 'www.instagram.com', + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of posts' }, + ], + columns: ['rank', 'user', 'caption', 'likes', 'comments', 'type'], + pipeline: [ + { navigate: 'https://www.instagram.com' }, + { evaluate: `(async () => { + const limit = \${{ args.limit }}; + const res = await fetch( + 'https://www.instagram.com/api/v1/discover/web/explore_grid/', + { + credentials: 'include', + headers: { 'X-IG-App-ID': '936619743392459' } + } + ); + if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram'); + const data = await res.json(); + const posts = []; + for (const sec of (data?.sectional_items || [])) { + for (const m of (sec?.layout_content?.medias || [])) { + const media = m?.media; + if (media) posts.push({ + user: media.user?.username || '', + caption: (media.caption?.text || '').replace(/\\n/g, ' ').substring(0, 100), + likes: media.like_count ?? 0, + comments: media.comment_count ?? 0, + type: media.media_type === 1 ? 'photo' : media.media_type === 2 ? 'video' : 'carousel', + }); + } + } + return posts.slice(0, limit).map((p, i) => ({ rank: i + 1, ...p })); +})() +` }, + ], +}); diff --git a/clis/instagram/explore.yaml b/clis/instagram/explore.yaml deleted file mode 100644 index 9805938f..00000000 --- a/clis/instagram/explore.yaml +++ /dev/null @@ -1,43 +0,0 @@ -site: instagram -name: explore -description: Instagram explore/discover trending posts -domain: www.instagram.com - -args: - limit: - type: int - default: 20 - description: Number of posts - -pipeline: - - navigate: https://www.instagram.com - - - evaluate: | - (async () => { - const limit = ${{ args.limit }}; - const res = await fetch( - 'https://www.instagram.com/api/v1/discover/web/explore_grid/', - { - credentials: 'include', - headers: { 'X-IG-App-ID': '936619743392459' } - } - ); - if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram'); - const data = await res.json(); - const posts = []; - for (const sec of (data?.sectional_items || [])) { - for (const m of (sec?.layout_content?.medias || [])) { - const media = m?.media; - if (media) posts.push({ - user: media.user?.username || '', - caption: (media.caption?.text || '').replace(/\n/g, ' ').substring(0, 100), - likes: media.like_count ?? 0, - comments: media.comment_count ?? 0, - type: media.media_type === 1 ? 'photo' : media.media_type === 2 ? 'video' : 'carousel', - }); - } - } - return posts.slice(0, limit).map((p, i) => ({ rank: i + 1, ...p })); - })() - -columns: [rank, user, caption, likes, comments, type] diff --git a/clis/instagram/follow.ts b/clis/instagram/follow.ts new file mode 100644 index 00000000..08d061ad --- /dev/null +++ b/clis/instagram/follow.ts @@ -0,0 +1,44 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'instagram', + name: 'follow', + description: 'Follow an Instagram user', + domain: 'www.instagram.com', + args: [ + { + name: 'username', + required: true, + positional: true, + help: 'Instagram username to follow', + }, + ], + columns: ['status', 'username'], + pipeline: [ + { navigate: 'https://www.instagram.com' }, + { evaluate: `(async () => { + const username = \${{ args.username | json }}; + const headers = { 'X-IG-App-ID': '936619743392459' }; + const opts = { credentials: 'include', headers }; + + // Get user ID + const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts); + if (!r1.ok) throw new Error('User not found: ' + username); + const d1 = await r1.json(); + const userId = d1?.data?.user?.id; + if (!userId) throw new Error('User not found: ' + username); + + const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || ''; + const r2 = await fetch('https://www.instagram.com/api/v1/friendships/create/' + userId + '/', { + method: 'POST', + credentials: 'include', + headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + if (!r2.ok) throw new Error('Failed to follow: HTTP ' + r2.status); + const d2 = await r2.json(); + const status = d2?.friendship_status?.following ? 'Following' : d2?.friendship_status?.outgoing_request ? 'Request sent' : 'Followed'; + return [{ status, username }]; +})() +` }, + ], +}); diff --git a/clis/instagram/follow.yaml b/clis/instagram/follow.yaml deleted file mode 100644 index 6042f579..00000000 --- a/clis/instagram/follow.yaml +++ /dev/null @@ -1,41 +0,0 @@ -site: instagram -name: follow -description: Follow an Instagram user -domain: www.instagram.com - -args: - username: - type: str - required: true - positional: true - description: Instagram username to follow - -pipeline: - - navigate: https://www.instagram.com - - - evaluate: | - (async () => { - const username = ${{ args.username | json }}; - const headers = { 'X-IG-App-ID': '936619743392459' }; - const opts = { credentials: 'include', headers }; - - // Get user ID - const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts); - if (!r1.ok) throw new Error('User not found: ' + username); - const d1 = await r1.json(); - const userId = d1?.data?.user?.id; - if (!userId) throw new Error('User not found: ' + username); - - const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || ''; - const r2 = await fetch('https://www.instagram.com/api/v1/friendships/create/' + userId + '/', { - method: 'POST', - credentials: 'include', - headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' }, - }); - if (!r2.ok) throw new Error('Failed to follow: HTTP ' + r2.status); - const d2 = await r2.json(); - const status = d2?.friendship_status?.following ? 'Following' : d2?.friendship_status?.outgoing_request ? 'Request sent' : 'Followed'; - return [{ status, username }]; - })() - -columns: [status, username] diff --git a/clis/instagram/followers.ts b/clis/instagram/followers.ts new file mode 100644 index 00000000..702f1a06 --- /dev/null +++ b/clis/instagram/followers.ts @@ -0,0 +1,46 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'instagram', + name: 'followers', + description: 'List followers of an Instagram user', + domain: 'www.instagram.com', + args: [ + { name: 'username', required: true, positional: true, help: 'Instagram username' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of followers' }, + ], + columns: ['rank', 'username', 'name', 'verified', 'private'], + pipeline: [ + { navigate: 'https://www.instagram.com' }, + { evaluate: `(async () => { + const username = \${{ args.username | json }}; + const limit = \${{ args.limit }}; + const headers = { 'X-IG-App-ID': '936619743392459' }; + const opts = { credentials: 'include', headers }; + + const r1 = await fetch( + 'https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), + opts + ); + if (!r1.ok) throw new Error('HTTP ' + r1.status + ' - make sure you are logged in to Instagram'); + const d1 = await r1.json(); + const userId = d1?.data?.user?.id; + if (!userId) throw new Error('User not found: ' + username); + + const r2 = await fetch( + 'https://www.instagram.com/api/v1/friendships/' + userId + '/followers/?count=' + limit, + opts + ); + if (!r2.ok) throw new Error('Failed to fetch followers: HTTP ' + r2.status); + const d2 = await r2.json(); + return (d2?.users || []).slice(0, limit).map((u, i) => ({ + rank: i + 1, + username: u.username || '', + name: u.full_name || '', + verified: u.is_verified ? 'Yes' : 'No', + private: u.is_private ? 'Yes' : 'No', + })); +})() +` }, + ], +}); diff --git a/clis/instagram/followers.yaml b/clis/instagram/followers.yaml deleted file mode 100644 index b750d376..00000000 --- a/clis/instagram/followers.yaml +++ /dev/null @@ -1,51 +0,0 @@ -site: instagram -name: followers -description: List followers of an Instagram user -domain: www.instagram.com - -args: - username: - type: str - required: true - positional: true - description: Instagram username - limit: - type: int - default: 20 - description: Number of followers - -pipeline: - - navigate: https://www.instagram.com - - - evaluate: | - (async () => { - const username = ${{ args.username | json }}; - const limit = ${{ args.limit }}; - const headers = { 'X-IG-App-ID': '936619743392459' }; - const opts = { credentials: 'include', headers }; - - const r1 = await fetch( - 'https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), - opts - ); - if (!r1.ok) throw new Error('HTTP ' + r1.status + ' - make sure you are logged in to Instagram'); - const d1 = await r1.json(); - const userId = d1?.data?.user?.id; - if (!userId) throw new Error('User not found: ' + username); - - const r2 = await fetch( - 'https://www.instagram.com/api/v1/friendships/' + userId + '/followers/?count=' + limit, - opts - ); - if (!r2.ok) throw new Error('Failed to fetch followers: HTTP ' + r2.status); - const d2 = await r2.json(); - return (d2?.users || []).slice(0, limit).map((u, i) => ({ - rank: i + 1, - username: u.username || '', - name: u.full_name || '', - verified: u.is_verified ? 'Yes' : 'No', - private: u.is_private ? 'Yes' : 'No', - })); - })() - -columns: [rank, username, name, verified, private] diff --git a/clis/instagram/following.ts b/clis/instagram/following.ts new file mode 100644 index 00000000..95c549c2 --- /dev/null +++ b/clis/instagram/following.ts @@ -0,0 +1,46 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'instagram', + name: 'following', + description: 'List accounts an Instagram user is following', + domain: 'www.instagram.com', + args: [ + { name: 'username', required: true, positional: true, help: 'Instagram username' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of accounts' }, + ], + columns: ['rank', 'username', 'name', 'verified', 'private'], + pipeline: [ + { navigate: 'https://www.instagram.com' }, + { evaluate: `(async () => { + const username = \${{ args.username | json }}; + const limit = \${{ args.limit }}; + const headers = { 'X-IG-App-ID': '936619743392459' }; + const opts = { credentials: 'include', headers }; + + const r1 = await fetch( + 'https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), + opts + ); + if (!r1.ok) throw new Error('HTTP ' + r1.status + ' - make sure you are logged in to Instagram'); + const d1 = await r1.json(); + const userId = d1?.data?.user?.id; + if (!userId) throw new Error('User not found: ' + username); + + const r2 = await fetch( + 'https://www.instagram.com/api/v1/friendships/' + userId + '/following/?count=' + limit, + opts + ); + if (!r2.ok) throw new Error('Failed to fetch following: HTTP ' + r2.status); + const d2 = await r2.json(); + return (d2?.users || []).slice(0, limit).map((u, i) => ({ + rank: i + 1, + username: u.username || '', + name: u.full_name || '', + verified: u.is_verified ? 'Yes' : 'No', + private: u.is_private ? 'Yes' : 'No', + })); +})() +` }, + ], +}); diff --git a/clis/instagram/following.yaml b/clis/instagram/following.yaml deleted file mode 100644 index c14c3530..00000000 --- a/clis/instagram/following.yaml +++ /dev/null @@ -1,51 +0,0 @@ -site: instagram -name: following -description: List accounts an Instagram user is following -domain: www.instagram.com - -args: - username: - type: str - required: true - positional: true - description: Instagram username - limit: - type: int - default: 20 - description: Number of accounts - -pipeline: - - navigate: https://www.instagram.com - - - evaluate: | - (async () => { - const username = ${{ args.username | json }}; - const limit = ${{ args.limit }}; - const headers = { 'X-IG-App-ID': '936619743392459' }; - const opts = { credentials: 'include', headers }; - - const r1 = await fetch( - 'https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), - opts - ); - if (!r1.ok) throw new Error('HTTP ' + r1.status + ' - make sure you are logged in to Instagram'); - const d1 = await r1.json(); - const userId = d1?.data?.user?.id; - if (!userId) throw new Error('User not found: ' + username); - - const r2 = await fetch( - 'https://www.instagram.com/api/v1/friendships/' + userId + '/following/?count=' + limit, - opts - ); - if (!r2.ok) throw new Error('Failed to fetch following: HTTP ' + r2.status); - const d2 = await r2.json(); - return (d2?.users || []).slice(0, limit).map((u, i) => ({ - rank: i + 1, - username: u.username || '', - name: u.full_name || '', - verified: u.is_verified ? 'Yes' : 'No', - private: u.is_private ? 'Yes' : 'No', - })); - })() - -columns: [rank, username, name, verified, private] diff --git a/clis/instagram/like.ts b/clis/instagram/like.ts new file mode 100644 index 00000000..3a7a659d --- /dev/null +++ b/clis/instagram/like.ts @@ -0,0 +1,46 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'instagram', + name: 'like', + description: 'Like an Instagram post', + domain: 'www.instagram.com', + args: [ + { + name: 'username', + required: true, + positional: true, + help: 'Username of the post author', + }, + { name: 'index', type: 'int', default: 1, help: 'Post index (1 = most recent)' }, + ], + columns: ['status', 'user', 'post'], + pipeline: [ + { navigate: 'https://www.instagram.com' }, + { evaluate: `(async () => { + const username = \${{ args.username | json }}; + const idx = \${{ args.index }} - 1; + const headers = { 'X-IG-App-ID': '936619743392459' }; + const opts = { credentials: 'include', headers }; + + const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts); + if (!r1.ok) throw new Error('User not found: ' + username); + const userId = (await r1.json())?.data?.user?.id; + + const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts); + const posts = (await r2.json())?.items || []; + if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found, user has ' + posts.length + ' recent posts'); + const pk = posts[idx].pk; + const caption = (posts[idx].caption?.text || '').substring(0, 60); + + const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || ''; + const r3 = await fetch('https://www.instagram.com/api/v1/web/likes/' + pk + '/like/', { + method: 'POST', credentials: 'include', + headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + if (!r3.ok) throw new Error('Failed to like: HTTP ' + r3.status); + return [{ status: 'Liked', user: username, post: caption || '(post #' + (idx+1) + ')' }]; +})() +` }, + ], +}); diff --git a/clis/instagram/like.yaml b/clis/instagram/like.yaml deleted file mode 100644 index 6c1ebfb1..00000000 --- a/clis/instagram/like.yaml +++ /dev/null @@ -1,46 +0,0 @@ -site: instagram -name: like -description: Like an Instagram post -domain: www.instagram.com - -args: - username: - type: str - required: true - positional: true - description: Username of the post author - index: - type: int - default: 1 - description: Post index (1 = most recent) - -pipeline: - - navigate: https://www.instagram.com - - - evaluate: | - (async () => { - const username = ${{ args.username | json }}; - const idx = ${{ args.index }} - 1; - const headers = { 'X-IG-App-ID': '936619743392459' }; - const opts = { credentials: 'include', headers }; - - const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts); - if (!r1.ok) throw new Error('User not found: ' + username); - const userId = (await r1.json())?.data?.user?.id; - - const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts); - const posts = (await r2.json())?.items || []; - if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found, user has ' + posts.length + ' recent posts'); - const pk = posts[idx].pk; - const caption = (posts[idx].caption?.text || '').substring(0, 60); - - const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || ''; - const r3 = await fetch('https://www.instagram.com/api/v1/web/likes/' + pk + '/like/', { - method: 'POST', credentials: 'include', - headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' }, - }); - if (!r3.ok) throw new Error('Failed to like: HTTP ' + r3.status); - return [{ status: 'Liked', user: username, post: caption || '(post #' + (idx+1) + ')' }]; - })() - -columns: [status, user, post] diff --git a/clis/instagram/profile.ts b/clis/instagram/profile.ts new file mode 100644 index 00000000..2da0bcd6 --- /dev/null +++ b/clis/instagram/profile.ts @@ -0,0 +1,40 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'instagram', + name: 'profile', + description: 'Get Instagram user profile info', + domain: 'www.instagram.com', + args: [ + { name: 'username', required: true, positional: true, help: 'Instagram username' }, + ], + columns: ['username', 'name', 'followers', 'following', 'posts', 'verified', 'bio'], + pipeline: [ + { navigate: 'https://www.instagram.com' }, + { evaluate: `(async () => { + const username = \${{ args.username | json }}; + const res = await fetch( + 'https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), + { + credentials: 'include', + headers: { 'X-IG-App-ID': '936619743392459' } + } + ); + if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram'); + const data = await res.json(); + const u = data?.data?.user; + if (!u) throw new Error('User not found: ' + username); + return [{ + username: u.username, + name: u.full_name || '', + bio: (u.biography || '').replace(/\\n/g, ' ').substring(0, 120), + followers: u.edge_followed_by?.count ?? 0, + following: u.edge_follow?.count ?? 0, + posts: u.edge_owner_to_timeline_media?.count ?? 0, + verified: u.is_verified ? 'Yes' : 'No', + url: 'https://www.instagram.com/' + u.username, + }]; +})() +` }, + ], +}); diff --git a/clis/instagram/profile.yaml b/clis/instagram/profile.yaml deleted file mode 100644 index 5c108ef8..00000000 --- a/clis/instagram/profile.yaml +++ /dev/null @@ -1,42 +0,0 @@ -site: instagram -name: profile -description: Get Instagram user profile info -domain: www.instagram.com - -args: - username: - type: str - required: true - positional: true - description: Instagram username - -pipeline: - - navigate: https://www.instagram.com - - - evaluate: | - (async () => { - const username = ${{ args.username | json }}; - const res = await fetch( - 'https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), - { - credentials: 'include', - headers: { 'X-IG-App-ID': '936619743392459' } - } - ); - if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram'); - const data = await res.json(); - const u = data?.data?.user; - if (!u) throw new Error('User not found: ' + username); - return [{ - username: u.username, - name: u.full_name || '', - bio: (u.biography || '').replace(/\n/g, ' ').substring(0, 120), - followers: u.edge_followed_by?.count ?? 0, - following: u.edge_follow?.count ?? 0, - posts: u.edge_owner_to_timeline_media?.count ?? 0, - verified: u.is_verified ? 'Yes' : 'No', - url: 'https://www.instagram.com/' + u.username, - }]; - })() - -columns: [username, name, followers, following, posts, verified, bio] diff --git a/clis/instagram/save.ts b/clis/instagram/save.ts new file mode 100644 index 00000000..bbcda04c --- /dev/null +++ b/clis/instagram/save.ts @@ -0,0 +1,46 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'instagram', + name: 'save', + description: 'Save (bookmark) an Instagram post', + domain: 'www.instagram.com', + args: [ + { + name: 'username', + required: true, + positional: true, + help: 'Username of the post author', + }, + { name: 'index', type: 'int', default: 1, help: 'Post index (1 = most recent)' }, + ], + columns: ['status', 'user', 'post'], + pipeline: [ + { navigate: 'https://www.instagram.com' }, + { evaluate: `(async () => { + const username = \${{ args.username | json }}; + const idx = \${{ args.index }} - 1; + const headers = { 'X-IG-App-ID': '936619743392459' }; + const opts = { credentials: 'include', headers }; + + const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts); + if (!r1.ok) throw new Error('User not found: ' + username); + const userId = (await r1.json())?.data?.user?.id; + + const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts); + const posts = (await r2.json())?.items || []; + if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found'); + const pk = posts[idx].pk; + const caption = (posts[idx].caption?.text || '').substring(0, 60); + + const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || ''; + const r3 = await fetch('https://www.instagram.com/api/v1/web/save/' + pk + '/save/', { + method: 'POST', credentials: 'include', + headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + if (!r3.ok) throw new Error('Failed to save: HTTP ' + r3.status); + return [{ status: 'Saved', user: username, post: caption || '(post #' + (idx+1) + ')' }]; +})() +` }, + ], +}); diff --git a/clis/instagram/save.yaml b/clis/instagram/save.yaml deleted file mode 100644 index 796c064c..00000000 --- a/clis/instagram/save.yaml +++ /dev/null @@ -1,46 +0,0 @@ -site: instagram -name: save -description: Save (bookmark) an Instagram post -domain: www.instagram.com - -args: - username: - type: str - required: true - positional: true - description: Username of the post author - index: - type: int - default: 1 - description: Post index (1 = most recent) - -pipeline: - - navigate: https://www.instagram.com - - - evaluate: | - (async () => { - const username = ${{ args.username | json }}; - const idx = ${{ args.index }} - 1; - const headers = { 'X-IG-App-ID': '936619743392459' }; - const opts = { credentials: 'include', headers }; - - const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts); - if (!r1.ok) throw new Error('User not found: ' + username); - const userId = (await r1.json())?.data?.user?.id; - - const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts); - const posts = (await r2.json())?.items || []; - if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found'); - const pk = posts[idx].pk; - const caption = (posts[idx].caption?.text || '').substring(0, 60); - - const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || ''; - const r3 = await fetch('https://www.instagram.com/api/v1/web/save/' + pk + '/save/', { - method: 'POST', credentials: 'include', - headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' }, - }); - if (!r3.ok) throw new Error('Failed to save: HTTP ' + r3.status); - return [{ status: 'Saved', user: username, post: caption || '(post #' + (idx+1) + ')' }]; - })() - -columns: [status, user, post] diff --git a/clis/instagram/saved.ts b/clis/instagram/saved.ts new file mode 100644 index 00000000..3304ba29 --- /dev/null +++ b/clis/instagram/saved.ts @@ -0,0 +1,39 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'instagram', + name: 'saved', + description: 'Get your saved Instagram posts', + domain: 'www.instagram.com', + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of saved posts' }, + ], + columns: ['index', 'user', 'caption', 'likes', 'comments', 'type'], + pipeline: [ + { navigate: 'https://www.instagram.com' }, + { evaluate: `(async () => { + const limit = \${{ args.limit }}; + const res = await fetch( + 'https://www.instagram.com/api/v1/feed/saved/posts/', + { + credentials: 'include', + headers: { 'X-IG-App-ID': '936619743392459' } + } + ); + if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram'); + const data = await res.json(); + return (data?.items || []).slice(0, limit).map((item, i) => { + const m = item?.media; + return { + index: i + 1, + user: m?.user?.username || '', + caption: (m?.caption?.text || '').replace(/\\n/g, ' ').substring(0, 100), + likes: m?.like_count ?? 0, + comments: m?.comment_count ?? 0, + type: m?.media_type === 1 ? 'photo' : m?.media_type === 2 ? 'video' : 'carousel', + }; + }); +})() +` }, + ], +}); diff --git a/clis/instagram/saved.yaml b/clis/instagram/saved.yaml deleted file mode 100644 index 608885f9..00000000 --- a/clis/instagram/saved.yaml +++ /dev/null @@ -1,40 +0,0 @@ -site: instagram -name: saved -description: Get your saved Instagram posts -domain: www.instagram.com - -args: - limit: - type: int - default: 20 - description: Number of saved posts - -pipeline: - - navigate: https://www.instagram.com - - - evaluate: | - (async () => { - const limit = ${{ args.limit }}; - const res = await fetch( - 'https://www.instagram.com/api/v1/feed/saved/posts/', - { - credentials: 'include', - headers: { 'X-IG-App-ID': '936619743392459' } - } - ); - if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram'); - const data = await res.json(); - return (data?.items || []).slice(0, limit).map((item, i) => { - const m = item?.media; - return { - index: i + 1, - user: m?.user?.username || '', - caption: (m?.caption?.text || '').replace(/\n/g, ' ').substring(0, 100), - likes: m?.like_count ?? 0, - comments: m?.comment_count ?? 0, - type: m?.media_type === 1 ? 'photo' : m?.media_type === 2 ? 'video' : 'carousel', - }; - }); - })() - -columns: [index, user, caption, likes, comments, type] diff --git a/clis/instagram/search.ts b/clis/instagram/search.ts new file mode 100644 index 00000000..5627626b --- /dev/null +++ b/clis/instagram/search.ts @@ -0,0 +1,39 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'instagram', + name: 'search', + description: 'Search Instagram users', + domain: 'www.instagram.com', + args: [ + { name: 'query', required: true, positional: true, help: 'Search query' }, + { name: 'limit', type: 'int', default: 10, help: 'Number of results' }, + ], + columns: ['rank', 'username', 'name', 'verified', 'private', 'url'], + pipeline: [ + { navigate: 'https://www.instagram.com' }, + { evaluate: `(async () => { + const query = \${{ args.query | json }}; + const limit = \${{ args.limit }}; + const res = await fetch( + 'https://www.instagram.com/web/search/topsearch/?query=' + encodeURIComponent(query) + '&context=user', + { + credentials: 'include', + headers: { 'X-IG-App-ID': '936619743392459' } + } + ); + if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram'); + const data = await res.json(); + const users = (data?.users || []).slice(0, limit); + return users.map((item, i) => ({ + rank: i + 1, + username: item.user?.username || '', + name: item.user?.full_name || '', + verified: item.user?.is_verified ? 'Yes' : 'No', + private: item.user?.is_private ? 'Yes' : 'No', + url: 'https://www.instagram.com/' + (item.user?.username || ''), + })); +})() +` }, + ], +}); diff --git a/clis/instagram/search.yaml b/clis/instagram/search.yaml deleted file mode 100644 index d3ea01be..00000000 --- a/clis/instagram/search.yaml +++ /dev/null @@ -1,44 +0,0 @@ -site: instagram -name: search -description: Search Instagram users -domain: www.instagram.com - -args: - query: - type: str - required: true - positional: true - description: Search query - limit: - type: int - default: 10 - description: Number of results - -pipeline: - - navigate: https://www.instagram.com - - - evaluate: | - (async () => { - const query = ${{ args.query | json }}; - const limit = ${{ args.limit }}; - const res = await fetch( - 'https://www.instagram.com/web/search/topsearch/?query=' + encodeURIComponent(query) + '&context=user', - { - credentials: 'include', - headers: { 'X-IG-App-ID': '936619743392459' } - } - ); - if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram'); - const data = await res.json(); - const users = (data?.users || []).slice(0, limit); - return users.map((item, i) => ({ - rank: i + 1, - username: item.user?.username || '', - name: item.user?.full_name || '', - verified: item.user?.is_verified ? 'Yes' : 'No', - private: item.user?.is_private ? 'Yes' : 'No', - url: 'https://www.instagram.com/' + (item.user?.username || ''), - })); - })() - -columns: [rank, username, name, verified, private, url] diff --git a/clis/instagram/unfollow.ts b/clis/instagram/unfollow.ts new file mode 100644 index 00000000..39e09682 --- /dev/null +++ b/clis/instagram/unfollow.ts @@ -0,0 +1,41 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'instagram', + name: 'unfollow', + description: 'Unfollow an Instagram user', + domain: 'www.instagram.com', + args: [ + { + name: 'username', + required: true, + positional: true, + help: 'Instagram username to unfollow', + }, + ], + columns: ['status', 'username'], + pipeline: [ + { navigate: 'https://www.instagram.com' }, + { evaluate: `(async () => { + const username = \${{ args.username | json }}; + const headers = { 'X-IG-App-ID': '936619743392459' }; + const opts = { credentials: 'include', headers }; + + const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts); + if (!r1.ok) throw new Error('User not found: ' + username); + const d1 = await r1.json(); + const userId = d1?.data?.user?.id; + if (!userId) throw new Error('User not found: ' + username); + + const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || ''; + const r2 = await fetch('https://www.instagram.com/api/v1/friendships/destroy/' + userId + '/', { + method: 'POST', + credentials: 'include', + headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + if (!r2.ok) throw new Error('Failed to unfollow: HTTP ' + r2.status); + return [{ status: 'Unfollowed', username }]; +})() +` }, + ], +}); diff --git a/clis/instagram/unfollow.yaml b/clis/instagram/unfollow.yaml deleted file mode 100644 index f11f23bf..00000000 --- a/clis/instagram/unfollow.yaml +++ /dev/null @@ -1,38 +0,0 @@ -site: instagram -name: unfollow -description: Unfollow an Instagram user -domain: www.instagram.com - -args: - username: - type: str - required: true - positional: true - description: Instagram username to unfollow - -pipeline: - - navigate: https://www.instagram.com - - - evaluate: | - (async () => { - const username = ${{ args.username | json }}; - const headers = { 'X-IG-App-ID': '936619743392459' }; - const opts = { credentials: 'include', headers }; - - const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts); - if (!r1.ok) throw new Error('User not found: ' + username); - const d1 = await r1.json(); - const userId = d1?.data?.user?.id; - if (!userId) throw new Error('User not found: ' + username); - - const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || ''; - const r2 = await fetch('https://www.instagram.com/api/v1/friendships/destroy/' + userId + '/', { - method: 'POST', - credentials: 'include', - headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' }, - }); - if (!r2.ok) throw new Error('Failed to unfollow: HTTP ' + r2.status); - return [{ status: 'Unfollowed', username }]; - })() - -columns: [status, username] diff --git a/clis/instagram/unlike.ts b/clis/instagram/unlike.ts new file mode 100644 index 00000000..d55b8b8a --- /dev/null +++ b/clis/instagram/unlike.ts @@ -0,0 +1,46 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'instagram', + name: 'unlike', + description: 'Unlike an Instagram post', + domain: 'www.instagram.com', + args: [ + { + name: 'username', + required: true, + positional: true, + help: 'Username of the post author', + }, + { name: 'index', type: 'int', default: 1, help: 'Post index (1 = most recent)' }, + ], + columns: ['status', 'user', 'post'], + pipeline: [ + { navigate: 'https://www.instagram.com' }, + { evaluate: `(async () => { + const username = \${{ args.username | json }}; + const idx = \${{ args.index }} - 1; + const headers = { 'X-IG-App-ID': '936619743392459' }; + const opts = { credentials: 'include', headers }; + + const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts); + if (!r1.ok) throw new Error('User not found: ' + username); + const userId = (await r1.json())?.data?.user?.id; + + const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts); + const posts = (await r2.json())?.items || []; + if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found'); + const pk = posts[idx].pk; + const caption = (posts[idx].caption?.text || '').substring(0, 60); + + const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || ''; + const r3 = await fetch('https://www.instagram.com/api/v1/web/likes/' + pk + '/unlike/', { + method: 'POST', credentials: 'include', + headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + if (!r3.ok) throw new Error('Failed to unlike: HTTP ' + r3.status); + return [{ status: 'Unliked', user: username, post: caption || '(post #' + (idx+1) + ')' }]; +})() +` }, + ], +}); diff --git a/clis/instagram/unlike.yaml b/clis/instagram/unlike.yaml deleted file mode 100644 index 8c30b0da..00000000 --- a/clis/instagram/unlike.yaml +++ /dev/null @@ -1,46 +0,0 @@ -site: instagram -name: unlike -description: Unlike an Instagram post -domain: www.instagram.com - -args: - username: - type: str - required: true - positional: true - description: Username of the post author - index: - type: int - default: 1 - description: Post index (1 = most recent) - -pipeline: - - navigate: https://www.instagram.com - - - evaluate: | - (async () => { - const username = ${{ args.username | json }}; - const idx = ${{ args.index }} - 1; - const headers = { 'X-IG-App-ID': '936619743392459' }; - const opts = { credentials: 'include', headers }; - - const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts); - if (!r1.ok) throw new Error('User not found: ' + username); - const userId = (await r1.json())?.data?.user?.id; - - const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts); - const posts = (await r2.json())?.items || []; - if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found'); - const pk = posts[idx].pk; - const caption = (posts[idx].caption?.text || '').substring(0, 60); - - const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || ''; - const r3 = await fetch('https://www.instagram.com/api/v1/web/likes/' + pk + '/unlike/', { - method: 'POST', credentials: 'include', - headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' }, - }); - if (!r3.ok) throw new Error('Failed to unlike: HTTP ' + r3.status); - return [{ status: 'Unliked', user: username, post: caption || '(post #' + (idx+1) + ')' }]; - })() - -columns: [status, user, post] diff --git a/clis/instagram/unsave.ts b/clis/instagram/unsave.ts new file mode 100644 index 00000000..28d04217 --- /dev/null +++ b/clis/instagram/unsave.ts @@ -0,0 +1,46 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'instagram', + name: 'unsave', + description: 'Unsave (remove bookmark) an Instagram post', + domain: 'www.instagram.com', + args: [ + { + name: 'username', + required: true, + positional: true, + help: 'Username of the post author', + }, + { name: 'index', type: 'int', default: 1, help: 'Post index (1 = most recent)' }, + ], + columns: ['status', 'user', 'post'], + pipeline: [ + { navigate: 'https://www.instagram.com' }, + { evaluate: `(async () => { + const username = \${{ args.username | json }}; + const idx = \${{ args.index }} - 1; + const headers = { 'X-IG-App-ID': '936619743392459' }; + const opts = { credentials: 'include', headers }; + + const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts); + if (!r1.ok) throw new Error('User not found: ' + username); + const userId = (await r1.json())?.data?.user?.id; + + const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts); + const posts = (await r2.json())?.items || []; + if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found'); + const pk = posts[idx].pk; + const caption = (posts[idx].caption?.text || '').substring(0, 60); + + const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || ''; + const r3 = await fetch('https://www.instagram.com/api/v1/web/save/' + pk + '/unsave/', { + method: 'POST', credentials: 'include', + headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + if (!r3.ok) throw new Error('Failed to unsave: HTTP ' + r3.status); + return [{ status: 'Unsaved', user: username, post: caption || '(post #' + (idx+1) + ')' }]; +})() +` }, + ], +}); diff --git a/clis/instagram/unsave.yaml b/clis/instagram/unsave.yaml deleted file mode 100644 index ace81725..00000000 --- a/clis/instagram/unsave.yaml +++ /dev/null @@ -1,46 +0,0 @@ -site: instagram -name: unsave -description: Unsave (remove bookmark) an Instagram post -domain: www.instagram.com - -args: - username: - type: str - required: true - positional: true - description: Username of the post author - index: - type: int - default: 1 - description: Post index (1 = most recent) - -pipeline: - - navigate: https://www.instagram.com - - - evaluate: | - (async () => { - const username = ${{ args.username | json }}; - const idx = ${{ args.index }} - 1; - const headers = { 'X-IG-App-ID': '936619743392459' }; - const opts = { credentials: 'include', headers }; - - const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts); - if (!r1.ok) throw new Error('User not found: ' + username); - const userId = (await r1.json())?.data?.user?.id; - - const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts); - const posts = (await r2.json())?.items || []; - if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found'); - const pk = posts[idx].pk; - const caption = (posts[idx].caption?.text || '').substring(0, 60); - - const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || ''; - const r3 = await fetch('https://www.instagram.com/api/v1/web/save/' + pk + '/unsave/', { - method: 'POST', credentials: 'include', - headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' }, - }); - if (!r3.ok) throw new Error('Failed to unsave: HTTP ' + r3.status); - return [{ status: 'Unsaved', user: username, post: caption || '(post #' + (idx+1) + ')' }]; - })() - -columns: [status, user, post] diff --git a/clis/instagram/user.ts b/clis/instagram/user.ts new file mode 100644 index 00000000..721514cb --- /dev/null +++ b/clis/instagram/user.ts @@ -0,0 +1,49 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'instagram', + name: 'user', + description: 'Get recent posts from an Instagram user', + domain: 'www.instagram.com', + args: [ + { name: 'username', required: true, positional: true, help: 'Instagram username' }, + { name: 'limit', type: 'int', default: 12, help: 'Number of posts' }, + ], + columns: ['index', 'caption', 'likes', 'comments', 'type', 'date'], + pipeline: [ + { navigate: 'https://www.instagram.com' }, + { evaluate: `(async () => { + const username = \${{ args.username | json }}; + const limit = \${{ args.limit }}; + const headers = { 'X-IG-App-ID': '936619743392459' }; + const opts = { credentials: 'include', headers }; + + // Get user ID first + const r1 = await fetch( + 'https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), + opts + ); + if (!r1.ok) throw new Error('HTTP ' + r1.status + ' - make sure you are logged in to Instagram'); + const d1 = await r1.json(); + const userId = d1?.data?.user?.id; + if (!userId) throw new Error('User not found: ' + username); + + // Get user feed + const r2 = await fetch( + 'https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + limit, + opts + ); + if (!r2.ok) throw new Error('Failed to fetch user feed: HTTP ' + r2.status); + const d2 = await r2.json(); + return (d2?.items || []).slice(0, limit).map((p, i) => ({ + index: i + 1, + caption: (p.caption?.text || '').replace(/\\n/g, ' ').substring(0, 100), + likes: p.like_count ?? 0, + comments: p.comment_count ?? 0, + type: p.media_type === 1 ? 'photo' : p.media_type === 2 ? 'video' : 'carousel', + date: p.taken_at ? new Date(p.taken_at * 1000).toLocaleDateString() : '', + })); +})() +` }, + ], +}); diff --git a/clis/instagram/user.yaml b/clis/instagram/user.yaml deleted file mode 100644 index 2cae3dd8..00000000 --- a/clis/instagram/user.yaml +++ /dev/null @@ -1,54 +0,0 @@ -site: instagram -name: user -description: Get recent posts from an Instagram user -domain: www.instagram.com - -args: - username: - type: str - required: true - positional: true - description: Instagram username - limit: - type: int - default: 12 - description: Number of posts - -pipeline: - - navigate: https://www.instagram.com - - - evaluate: | - (async () => { - const username = ${{ args.username | json }}; - const limit = ${{ args.limit }}; - const headers = { 'X-IG-App-ID': '936619743392459' }; - const opts = { credentials: 'include', headers }; - - // Get user ID first - const r1 = await fetch( - 'https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), - opts - ); - if (!r1.ok) throw new Error('HTTP ' + r1.status + ' - make sure you are logged in to Instagram'); - const d1 = await r1.json(); - const userId = d1?.data?.user?.id; - if (!userId) throw new Error('User not found: ' + username); - - // Get user feed - const r2 = await fetch( - 'https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + limit, - opts - ); - if (!r2.ok) throw new Error('Failed to fetch user feed: HTTP ' + r2.status); - const d2 = await r2.json(); - return (d2?.items || []).slice(0, limit).map((p, i) => ({ - index: i + 1, - caption: (p.caption?.text || '').replace(/\n/g, ' ').substring(0, 100), - likes: p.like_count ?? 0, - comments: p.comment_count ?? 0, - type: p.media_type === 1 ? 'photo' : p.media_type === 2 ? 'video' : 'carousel', - date: p.taken_at ? new Date(p.taken_at * 1000).toLocaleDateString() : '', - })); - })() - -columns: [index, caption, likes, comments, type, date] diff --git a/clis/jike/post.ts b/clis/jike/post.ts new file mode 100644 index 00000000..cc610f11 --- /dev/null +++ b/clis/jike/post.ts @@ -0,0 +1,62 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'jike', + name: 'post', + description: '即刻帖子详情及评论', + domain: 'm.okjike.com', + browser: true, + args: [ + { + name: 'id', + type: 'string', + required: true, + positional: true, + help: 'Post ID (from post URL)', + }, + ], + columns: ['type', 'author', 'content', 'likes', 'time'], + pipeline: [ + { navigate: 'https://m.okjike.com/originalPosts/${{ args.id }}' }, + { evaluate: `(() => { + try { + const el = document.querySelector('script[type="application/json"]'); + if (!el) return []; + const data = JSON.parse(el.textContent); + const pageProps = data?.props?.pageProps || {}; + const post = pageProps.post || {}; + const comments = pageProps.comments || []; + + const result = [{ + type: 'post', + author: post.user?.screenName || '', + content: post.content || '', + likes: post.likeCount || 0, + time: post.createdAt || '', + }]; + + for (const c of comments) { + result.push({ + type: 'comment', + author: c.user?.screenName || '', + content: (c.content || '').replace(/\\n/g, ' '), + likes: c.likeCount || 0, + time: c.createdAt || '', + }); + } + + return result; + } catch (e) { + return []; + } +})() +` }, + { map: { + type: '${{ item.type }}', + author: '${{ item.author }}', + content: '${{ item.content }}', + likes: '${{ item.likes }}', + time: '${{ item.time }}', + } }, + ], +}); diff --git a/clis/jike/post.yaml b/clis/jike/post.yaml deleted file mode 100644 index c86c3238..00000000 --- a/clis/jike/post.yaml +++ /dev/null @@ -1,59 +0,0 @@ -site: jike -name: post -description: 即刻帖子详情及评论 -domain: m.okjike.com -browser: true - -args: - id: - positional: true - type: string - required: true - description: Post ID (from post URL) - -pipeline: - - navigate: https://m.okjike.com/originalPosts/${{ args.id }} - - # 从 Next.js SSR 内嵌 JSON 中提取帖子和评论 - - evaluate: | - (() => { - try { - const el = document.querySelector('script[type="application/json"]'); - if (!el) return []; - const data = JSON.parse(el.textContent); - const pageProps = data?.props?.pageProps || {}; - const post = pageProps.post || {}; - const comments = pageProps.comments || []; - - const result = [{ - type: 'post', - author: post.user?.screenName || '', - content: post.content || '', - likes: post.likeCount || 0, - time: post.createdAt || '', - }]; - - for (const c of comments) { - result.push({ - type: 'comment', - author: c.user?.screenName || '', - content: (c.content || '').replace(/\n/g, ' '), - likes: c.likeCount || 0, - time: c.createdAt || '', - }); - } - - return result; - } catch (e) { - return []; - } - })() - - - map: - type: ${{ item.type }} - author: ${{ item.author }} - content: ${{ item.content }} - likes: ${{ item.likes }} - time: ${{ item.time }} - -columns: [type, author, content, likes, time] diff --git a/clis/jike/topic.ts b/clis/jike/topic.ts new file mode 100644 index 00000000..e9010688 --- /dev/null +++ b/clis/jike/topic.ts @@ -0,0 +1,52 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'jike', + name: 'topic', + description: '即刻话题/圈子帖子', + domain: 'm.okjike.com', + browser: true, + args: [ + { + name: 'id', + type: 'string', + required: true, + positional: true, + help: 'Topic ID (from topic URL, e.g. 553870e8e4b0cafb0a1bef68)', + }, + { name: 'limit', type: 'int', default: 20, help: 'Number of posts' }, + ], + columns: ['content', 'author', 'likes', 'comments', 'time', 'url'], + pipeline: [ + { navigate: 'https://m.okjike.com/topics/${{ args.id }}' }, + { evaluate: `(() => { + try { + const el = document.querySelector('script[type="application/json"]'); + if (!el) return []; + const data = JSON.parse(el.textContent); + const pageProps = data?.props?.pageProps || {}; + const posts = pageProps.posts || []; + return posts.map(p => ({ + content: (p.content || '').replace(/\\n/g, ' ').slice(0, 80), + author: p.user?.screenName || '', + likes: p.likeCount || 0, + comments: p.commentCount || 0, + time: p.actionTime || p.createdAt || '', + id: p.id || '', + })); + } catch (e) { + return []; + } +})() +` }, + { map: { + content: '${{ item.content }}', + author: '${{ item.author }}', + likes: '${{ item.likes }}', + comments: '${{ item.comments }}', + time: '${{ item.time }}', + url: 'https://web.okjike.com/originalPost/${{ item.id }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/jike/topic.yaml b/clis/jike/topic.yaml deleted file mode 100644 index e3e48c8c..00000000 --- a/clis/jike/topic.yaml +++ /dev/null @@ -1,53 +0,0 @@ -site: jike -name: topic -description: 即刻话题/圈子帖子 -domain: m.okjike.com -browser: true - -args: - id: - positional: true - type: string - required: true - description: Topic ID (from topic URL, e.g. 553870e8e4b0cafb0a1bef68) - limit: - type: int - default: 20 - description: Number of posts - -pipeline: - - navigate: https://m.okjike.com/topics/${{ args.id }} - - # 从 Next.js SSR 内嵌 JSON 中提取话题帖子 - - evaluate: | - (() => { - try { - const el = document.querySelector('script[type="application/json"]'); - if (!el) return []; - const data = JSON.parse(el.textContent); - const pageProps = data?.props?.pageProps || {}; - const posts = pageProps.posts || []; - return posts.map(p => ({ - content: (p.content || '').replace(/\n/g, ' ').slice(0, 80), - author: p.user?.screenName || '', - likes: p.likeCount || 0, - comments: p.commentCount || 0, - time: p.actionTime || p.createdAt || '', - id: p.id || '', - })); - } catch (e) { - return []; - } - })() - - - map: - content: ${{ item.content }} - author: ${{ item.author }} - likes: ${{ item.likes }} - comments: ${{ item.comments }} - time: ${{ item.time }} - url: https://web.okjike.com/originalPost/${{ item.id }} - - - limit: ${{ args.limit }} - -columns: [content, author, likes, comments, time, url] diff --git a/clis/jike/user.ts b/clis/jike/user.ts new file mode 100644 index 00000000..09e7f733 --- /dev/null +++ b/clis/jike/user.ts @@ -0,0 +1,51 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'jike', + name: 'user', + description: '即刻用户动态', + domain: 'm.okjike.com', + browser: true, + args: [ + { + name: 'username', + type: 'string', + required: true, + positional: true, + help: 'Username from profile URL (e.g. wenhao1996)', + }, + { name: 'limit', type: 'int', default: 20, help: 'Number of posts' }, + ], + columns: ['content', 'type', 'likes', 'comments', 'time', 'url'], + pipeline: [ + { navigate: 'https://m.okjike.com/users/${{ args.username }}' }, + { evaluate: `(() => { + try { + const el = document.querySelector('script[type="application/json"]'); + if (!el) return []; + const data = JSON.parse(el.textContent); + const posts = data?.props?.pageProps?.posts || []; + return posts.map(p => ({ + content: (p.content || '').replace(/\\n/g, ' ').slice(0, 80), + type: p.type === 'ORIGINAL_POST' ? 'post' : p.type === 'REPOST' ? 'repost' : p.type || '', + likes: p.likeCount || 0, + comments: p.commentCount || 0, + time: p.actionTime || p.createdAt || '', + id: p.id || '', + })); + } catch (e) { + return []; + } +})() +` }, + { map: { + content: '${{ item.content }}', + type: '${{ item.type }}', + likes: '${{ item.likes }}', + comments: '${{ item.comments }}', + time: '${{ item.time }}', + url: 'https://web.okjike.com/originalPost/${{ item.id }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/jike/user.yaml b/clis/jike/user.yaml deleted file mode 100644 index 00c792db..00000000 --- a/clis/jike/user.yaml +++ /dev/null @@ -1,52 +0,0 @@ -site: jike -name: user -description: 即刻用户动态 -domain: m.okjike.com -browser: true - -args: - username: - positional: true - type: string - required: true - description: Username from profile URL (e.g. wenhao1996) - limit: - type: int - default: 20 - description: Number of posts - -pipeline: - - navigate: https://m.okjike.com/users/${{ args.username }} - - # 从 Next.js SSR 内嵌 JSON 中提取用户动态 - - evaluate: | - (() => { - try { - const el = document.querySelector('script[type="application/json"]'); - if (!el) return []; - const data = JSON.parse(el.textContent); - const posts = data?.props?.pageProps?.posts || []; - return posts.map(p => ({ - content: (p.content || '').replace(/\n/g, ' ').slice(0, 80), - type: p.type === 'ORIGINAL_POST' ? 'post' : p.type === 'REPOST' ? 'repost' : p.type || '', - likes: p.likeCount || 0, - comments: p.commentCount || 0, - time: p.actionTime || p.createdAt || '', - id: p.id || '', - })); - } catch (e) { - return []; - } - })() - - - map: - content: ${{ item.content }} - type: ${{ item.type }} - likes: ${{ item.likes }} - comments: ${{ item.comments }} - time: ${{ item.time }} - url: https://web.okjike.com/originalPost/${{ item.id }} - - - limit: ${{ args.limit }} - -columns: [content, type, likes, comments, time, url] diff --git a/clis/jimeng/generate.ts b/clis/jimeng/generate.ts new file mode 100644 index 00000000..24e8dfcd --- /dev/null +++ b/clis/jimeng/generate.ts @@ -0,0 +1,84 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'jimeng', + name: 'generate', + description: '即梦AI 文生图 — 输入 prompt 生成图片', + domain: 'jimeng.jianying.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'prompt', type: 'string', required: true, positional: true, help: '图片描述 prompt' }, + { + name: 'model', + type: 'string', + default: 'high_aes_general_v50', + help: '模型: high_aes_general_v50 (5.0 Lite), high_aes_general_v42 (4.6), high_aes_general_v40 (4.0)', + }, + { name: 'wait', type: 'int', default: 40, help: '等待生成完成的秒数' }, + ], + columns: ['status', 'prompt', 'image_count', 'image_urls'], + pipeline: [ + { navigate: 'https://jimeng.jianying.com/ai-tool/generate?type=image&workspace=0' }, + { wait: 3 }, + { evaluate: `(async () => { + const prompt = \${{ args.prompt | json }}; + const waitSec = \${{ args.wait }}; + + // Step 1: Count existing images before generation + const beforeImgs = document.querySelectorAll('img[src*="dreamina-sign"], img[src*="tb4s082cfz"]').length; + + // Step 2: Clear and set prompt + const editors = document.querySelectorAll('[contenteditable="true"]'); + const editor = editors[0]; + if (!editor) return [{ status: 'failed', prompt: prompt, image_count: 0, image_urls: 'Editor not found' }]; + + editor.focus(); + await new Promise(r => setTimeout(r, 200)); + document.execCommand('selectAll'); + await new Promise(r => setTimeout(r, 100)); + document.execCommand('delete'); + await new Promise(r => setTimeout(r, 200)); + document.execCommand('insertText', false, prompt); + await new Promise(r => setTimeout(r, 500)); + + // Step 3: Click generate + const btn = document.querySelector('.lv-btn.lv-btn-primary[class*="circle"]'); + if (!btn) return [{ status: 'failed', prompt: prompt, image_count: 0, image_urls: 'Generate button not found' }]; + btn.click(); + + // Step 4: Wait for new images to appear + let newImgs = []; + for (let i = 0; i < waitSec; i++) { + await new Promise(r => setTimeout(r, 1000)); + const allImgs = document.querySelectorAll('img[src*="dreamina-sign"], img[src*="tb4s082cfz"]'); + if (allImgs.length > beforeImgs) { + // New images appeared — generation complete + newImgs = Array.from(allImgs).slice(0, allImgs.length - beforeImgs); + break; + } + } + + if (newImgs.length === 0) { + return [{ status: 'timeout', prompt: prompt, image_count: 0, image_urls: 'Generation may still be in progress' }]; + } + + // Step 5: Extract image URLs (use thumbnail URLs which are accessible) + const urls = newImgs.map(img => img.src); + + return [{ + status: 'success', + prompt: prompt.substring(0, 80), + image_count: urls.length, + image_urls: urls.join('\\n') + }]; +})() +` }, + { map: { + status: '${{ item.status }}', + prompt: '${{ item.prompt }}', + image_count: '${{ item.image_count }}', + image_urls: '${{ item.image_urls }}', + } }, + ], +}); diff --git a/clis/jimeng/generate.yaml b/clis/jimeng/generate.yaml deleted file mode 100644 index 22f6963d..00000000 --- a/clis/jimeng/generate.yaml +++ /dev/null @@ -1,85 +0,0 @@ -site: jimeng -name: generate -description: 即梦AI 文生图 — 输入 prompt 生成图片 -domain: jimeng.jianying.com -strategy: cookie -browser: true - -args: - prompt: - positional: true - type: string - required: true - description: "图片描述 prompt" - model: - type: string - default: "high_aes_general_v50" - description: "模型: high_aes_general_v50 (5.0 Lite), high_aes_general_v42 (4.6), high_aes_general_v40 (4.0)" - wait: - type: int - default: 40 - description: "等待生成完成的秒数" - -columns: [status, prompt, image_count, image_urls] - -pipeline: - - navigate: https://jimeng.jianying.com/ai-tool/generate?type=image&workspace=0 - - wait: 3 - - evaluate: | - (async () => { - const prompt = ${{ args.prompt | json }}; - const waitSec = ${{ args.wait }}; - - // Step 1: Count existing images before generation - const beforeImgs = document.querySelectorAll('img[src*="dreamina-sign"], img[src*="tb4s082cfz"]').length; - - // Step 2: Clear and set prompt - const editors = document.querySelectorAll('[contenteditable="true"]'); - const editor = editors[0]; - if (!editor) return [{ status: 'failed', prompt: prompt, image_count: 0, image_urls: 'Editor not found' }]; - - editor.focus(); - await new Promise(r => setTimeout(r, 200)); - document.execCommand('selectAll'); - await new Promise(r => setTimeout(r, 100)); - document.execCommand('delete'); - await new Promise(r => setTimeout(r, 200)); - document.execCommand('insertText', false, prompt); - await new Promise(r => setTimeout(r, 500)); - - // Step 3: Click generate - const btn = document.querySelector('.lv-btn.lv-btn-primary[class*="circle"]'); - if (!btn) return [{ status: 'failed', prompt: prompt, image_count: 0, image_urls: 'Generate button not found' }]; - btn.click(); - - // Step 4: Wait for new images to appear - let newImgs = []; - for (let i = 0; i < waitSec; i++) { - await new Promise(r => setTimeout(r, 1000)); - const allImgs = document.querySelectorAll('img[src*="dreamina-sign"], img[src*="tb4s082cfz"]'); - if (allImgs.length > beforeImgs) { - // New images appeared — generation complete - newImgs = Array.from(allImgs).slice(0, allImgs.length - beforeImgs); - break; - } - } - - if (newImgs.length === 0) { - return [{ status: 'timeout', prompt: prompt, image_count: 0, image_urls: 'Generation may still be in progress' }]; - } - - // Step 5: Extract image URLs (use thumbnail URLs which are accessible) - const urls = newImgs.map(img => img.src); - - return [{ - status: 'success', - prompt: prompt.substring(0, 80), - image_count: urls.length, - image_urls: urls.join('\n') - }]; - })() - - map: - status: ${{ item.status }} - prompt: ${{ item.prompt }} - image_count: ${{ item.image_count }} - image_urls: ${{ item.image_urls }} diff --git a/clis/jimeng/history.ts b/clis/jimeng/history.ts new file mode 100644 index 00000000..a189969c --- /dev/null +++ b/clis/jimeng/history.ts @@ -0,0 +1,48 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'jimeng', + name: 'history', + description: '即梦AI 查看最近生成的作品', + domain: 'jimeng.jianying.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'limit', type: 'int', default: 5 }, + ], + columns: ['prompt', 'model', 'status', 'image_url', 'created_at'], + pipeline: [ + { navigate: 'https://jimeng.jianying.com/ai-tool/generate?type=image&workspace=0' }, + { evaluate: `(async () => { + const limit = \${{ args.limit }}; + const res = await fetch('/mweb/v1/get_history?aid=513695&device_platform=web®ion=cn&da_version=3.3.11&web_version=7.5.0&aigc_features=app_lip_sync', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cursor: '', count: limit, need_page_item: true, need_aigc_data: true, aigc_mode_list: ['workbench'] }) + }); + const data = await res.json(); + const items = data?.data?.history_list || []; + return items.slice(0, limit).map(item => { + const params = item.aigc_image_params?.text2image_params || {}; + const images = item.image?.large_images || []; + return { + prompt: params.prompt || item.common_attr?.title || 'N/A', + model: params.model_config?.model_name || 'unknown', + status: item.common_attr?.status === 102 ? 'completed' : 'pending', + image_url: images[0]?.image_url || '', + created_at: new Date((item.common_attr?.create_time || 0) * 1000).toLocaleString('zh-CN'), + }; + }); +})() +` }, + { map: { + prompt: '${{ item.prompt }}', + model: '${{ item.model }}', + status: '${{ item.status }}', + image_url: '${{ item.image_url }}', + created_at: '${{ item.created_at }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/jimeng/history.yaml b/clis/jimeng/history.yaml deleted file mode 100644 index 26bcb881..00000000 --- a/clis/jimeng/history.yaml +++ /dev/null @@ -1,46 +0,0 @@ -site: jimeng -name: history -description: 即梦AI 查看最近生成的作品 -domain: jimeng.jianying.com -strategy: cookie -browser: true - -args: - limit: - type: int - default: 5 - -columns: [prompt, model, status, image_url, created_at] - -pipeline: - - navigate: https://jimeng.jianying.com/ai-tool/generate?type=image&workspace=0 - - evaluate: | - (async () => { - const limit = ${{ args.limit }}; - const res = await fetch('/mweb/v1/get_history?aid=513695&device_platform=web®ion=cn&da_version=3.3.11&web_version=7.5.0&aigc_features=app_lip_sync', { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cursor: '', count: limit, need_page_item: true, need_aigc_data: true, aigc_mode_list: ['workbench'] }) - }); - const data = await res.json(); - const items = data?.data?.history_list || []; - return items.slice(0, limit).map(item => { - const params = item.aigc_image_params?.text2image_params || {}; - const images = item.image?.large_images || []; - return { - prompt: params.prompt || item.common_attr?.title || 'N/A', - model: params.model_config?.model_name || 'unknown', - status: item.common_attr?.status === 102 ? 'completed' : 'pending', - image_url: images[0]?.image_url || '', - created_at: new Date((item.common_attr?.create_time || 0) * 1000).toLocaleString('zh-CN'), - }; - }); - })() - - map: - prompt: ${{ item.prompt }} - model: ${{ item.model }} - status: ${{ item.status }} - image_url: ${{ item.image_url }} - created_at: ${{ item.created_at }} - - limit: ${{ args.limit }} diff --git a/clis/linux-do/categories.ts b/clis/linux-do/categories.ts new file mode 100644 index 00000000..a54a4eba --- /dev/null +++ b/clis/linux-do/categories.ts @@ -0,0 +1,66 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'linux-do', + name: 'categories', + description: 'linux.do 分类列表', + domain: 'linux.do', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'subcategories', type: 'boolean', default: false, help: 'Include subcategories' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of categories' }, + ], + columns: ['name', 'slug', 'id', 'topics', 'description'], + pipeline: [ + { navigate: 'https://linux.do' }, + { evaluate: `(async () => { + const res = await fetch('/categories.json', { credentials: 'include' }); + if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do'); + let data; + try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); } + const cats = data?.category_list?.categories || []; + const showSub = \${{ args.subcategories }}; + const results = []; + const limit = \${{ args.limit }}; + for (const c of cats.slice(0, \${{ args.limit }})) { + results.push({ + name: c.name, + slug: c.slug, + id: c.id, + topics: c.topic_count, + description: (c.description_text || '').slice(0, 80), + }); + if (results.length >= limit) break; + if (showSub && c.subcategory_ids && c.subcategory_ids.length > 0) { + const subRes = await fetch('/categories.json?parent_category_id=' + c.id, { credentials: 'include' }); + if (subRes.ok) { + let subData; + try { subData = await subRes.json(); } catch { continue; } + const subCats = subData?.category_list?.categories || []; + for (const sc of subCats) { + results.push({ + name: c.name + ' / ' + sc.name, + slug: sc.slug, + id: sc.id, + topics: sc.topic_count, + description: (sc.description_text || '').slice(0, 80), + }); + if (results.length >= limit) break; + } + } + } + if (results.length >= limit) break; + } + return results; +})() +` }, + { map: { + name: '${{ item.name }}', + slug: '${{ item.slug }}', + id: '${{ item.id }}', + topics: '${{ item.topics }}', + description: '${{ item.description }}', + } }, + ], +}); diff --git a/clis/linux-do/categories.yaml b/clis/linux-do/categories.yaml deleted file mode 100644 index 1ce7a0df..00000000 --- a/clis/linux-do/categories.yaml +++ /dev/null @@ -1,70 +0,0 @@ -site: linux-do -name: categories -description: linux.do 分类列表 -domain: linux.do -strategy: cookie -browser: true - -args: - subcategories: - type: boolean - default: false - description: Include subcategories - limit: - type: int - default: 20 - description: Number of categories - -pipeline: - - navigate: https://linux.do - - - evaluate: | - (async () => { - const res = await fetch('/categories.json', { credentials: 'include' }); - if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do'); - let data; - try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); } - const cats = data?.category_list?.categories || []; - const showSub = ${{ args.subcategories }}; - const results = []; - const limit = ${{ args.limit }}; - for (const c of cats.slice(0, ${{ args.limit }})) { - results.push({ - name: c.name, - slug: c.slug, - id: c.id, - topics: c.topic_count, - description: (c.description_text || '').slice(0, 80), - }); - if (results.length >= limit) break; - if (showSub && c.subcategory_ids && c.subcategory_ids.length > 0) { - const subRes = await fetch('/categories.json?parent_category_id=' + c.id, { credentials: 'include' }); - if (subRes.ok) { - let subData; - try { subData = await subRes.json(); } catch { continue; } - const subCats = subData?.category_list?.categories || []; - for (const sc of subCats) { - results.push({ - name: c.name + ' / ' + sc.name, - slug: sc.slug, - id: sc.id, - topics: sc.topic_count, - description: (sc.description_text || '').slice(0, 80), - }); - if (results.length >= limit) break; - } - } - } - if (results.length >= limit) break; - } - return results; - })() - - - map: - name: ${{ item.name }} - slug: ${{ item.slug }} - id: ${{ item.id }} - topics: ${{ item.topics }} - description: ${{ item.description }} - -columns: [name, slug, id, topics, description] diff --git a/clis/linux-do/search.ts b/clis/linux-do/search.ts new file mode 100644 index 00000000..f36e21ad --- /dev/null +++ b/clis/linux-do/search.ts @@ -0,0 +1,42 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'linux-do', + name: 'search', + description: '搜索 linux.do', + domain: 'linux.do', + browser: true, + args: [ + { name: 'query', required: true, positional: true, help: 'Search query' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of results' }, + ], + columns: ['rank', 'title', 'views', 'likes', 'replies', 'url'], + pipeline: [ + { navigate: 'https://linux.do' }, + { evaluate: `(async () => { + const keyword = \${{ args.query | json }}; + const res = await fetch('/search.json?q=' + encodeURIComponent(keyword), { credentials: 'include' }); + if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do'); + let data; + try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); } + const topics = data?.topics || []; + return topics.slice(0, \${{ args.limit }}).map(t => ({ + title: t.title, + views: t.views, + likes: t.like_count, + replies: (t.posts_count || 1) - 1, + url: 'https://linux.do/t/topic/' + t.id, + })); +})() +` }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + views: '${{ item.views }}', + likes: '${{ item.likes }}', + replies: '${{ item.replies }}', + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/linux-do/search.yaml b/clis/linux-do/search.yaml deleted file mode 100644 index c1f1b551..00000000 --- a/clis/linux-do/search.yaml +++ /dev/null @@ -1,48 +0,0 @@ -site: linux-do -name: search -description: 搜索 linux.do -domain: linux.do -browser: true - -args: - query: - positional: true - type: str - required: true - description: Search query - limit: - type: int - default: 20 - description: Number of results - -pipeline: - - navigate: https://linux.do - - - evaluate: | - (async () => { - const keyword = ${{ args.query | json }}; - const res = await fetch('/search.json?q=' + encodeURIComponent(keyword), { credentials: 'include' }); - if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do'); - let data; - try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); } - const topics = data?.topics || []; - return topics.slice(0, ${{ args.limit }}).map(t => ({ - title: t.title, - views: t.views, - likes: t.like_count, - replies: (t.posts_count || 1) - 1, - url: 'https://linux.do/t/topic/' + t.id, - })); - })() - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - views: ${{ item.views }} - likes: ${{ item.likes }} - replies: ${{ item.replies }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [rank, title, views, likes, replies, url] diff --git a/clis/linux-do/tags.ts b/clis/linux-do/tags.ts new file mode 100644 index 00000000..85da79a6 --- /dev/null +++ b/clis/linux-do/tags.ts @@ -0,0 +1,40 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'linux-do', + name: 'tags', + description: 'linux.do 标签列表', + domain: 'linux.do', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'limit', type: 'int', default: 30, help: 'Number of tags' }, + ], + columns: ['rank', 'name', 'count', 'url'], + pipeline: [ + { navigate: 'https://linux.do' }, + { evaluate: `(async () => { + const res = await fetch('/tags.json', { credentials: 'include' }); + if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do'); + let data; + try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); } + let tags = data?.tags || []; + tags.sort((a, b) => (b.count || 0) - (a.count || 0)); + return tags.slice(0, \${{ args.limit }}).map(t => ({ + id: t.id, + name: t.name || t.id, + slug: t.slug, + count: t.count || 0, + })); +})() +` }, + { map: { + rank: '${{ index + 1 }}', + name: '${{ item.name }}', + count: '${{ item.count }}', + slug: '${{ item.slug }}', + id: '${{ item.id }}', + url: 'https://linux.do/tag/${{ item.slug }}', + } }, + ], +}); diff --git a/clis/linux-do/tags.yaml b/clis/linux-do/tags.yaml deleted file mode 100644 index 76c6a014..00000000 --- a/clis/linux-do/tags.yaml +++ /dev/null @@ -1,41 +0,0 @@ -site: linux-do -name: tags -description: linux.do 标签列表 -domain: linux.do -strategy: cookie -browser: true - -args: - limit: - type: int - default: 30 - description: Number of tags - -pipeline: - - navigate: https://linux.do - - - evaluate: | - (async () => { - const res = await fetch('/tags.json', { credentials: 'include' }); - if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do'); - let data; - try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); } - let tags = data?.tags || []; - tags.sort((a, b) => (b.count || 0) - (a.count || 0)); - return tags.slice(0, ${{ args.limit }}).map(t => ({ - id: t.id, - name: t.name || t.id, - slug: t.slug, - count: t.count || 0, - })); - })() - - - map: - rank: ${{ index + 1 }} - name: ${{ item.name }} - count: ${{ item.count }} - slug: ${{ item.slug }} - id: ${{ item.id }} - url: https://linux.do/tag/${{ item.slug }} - -columns: [rank, name, count, url] diff --git a/clis/linux-do/topic-content.test.ts b/clis/linux-do/topic-content.test.ts index 66cf3dd9..4f251a6d 100644 --- a/clis/linux-do/topic-content.test.ts +++ b/clis/linux-do/topic-content.test.ts @@ -57,11 +57,11 @@ describe('linux-do topic-content', () => { expect(command?.columns).toEqual(['content']); }); - it('keeps topic yaml as a summarized first-page reader after the split', () => { - const topicYaml = fs.readFileSync(new URL('./topic.yaml', import.meta.url), 'utf8'); + it('keeps topic adapter as a summarized first-page reader after the split', () => { + const topicTs = fs.readFileSync(new URL('./topic.ts', import.meta.url), 'utf8'); - expect(topicYaml).not.toContain('main_only'); - expect(topicYaml).toContain('slice(0, 200)'); - expect(topicYaml).toContain('帖子首页摘要和回复'); + expect(topicTs).not.toContain('main_only'); + expect(topicTs).toContain('slice(0, 200)'); + expect(topicTs).toContain('帖子首页摘要和回复'); }); }); diff --git a/clis/linux-do/topic.ts b/clis/linux-do/topic.ts new file mode 100644 index 00000000..eb8c4006 --- /dev/null +++ b/clis/linux-do/topic.ts @@ -0,0 +1,57 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'linux-do', + name: 'topic', + description: 'linux.do 帖子首页摘要和回复(首屏)', + domain: 'linux.do', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'id', type: 'int', required: true, positional: true, help: 'Topic ID' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of posts' }, + ], + columns: ['author', 'content', 'likes', 'created_at'], + pipeline: [ + { navigate: 'https://linux.do' }, + { evaluate: `(async () => { + const toLocalTime = (utcStr) => { + if (!utcStr) return ''; + const date = new Date(utcStr); + return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString(); + }; + const res = await fetch('/t/\${{ args.id }}.json', { credentials: 'include' }); + if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do'); + let data; + try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); } + const strip = (html) => (html || '') + .replace(//gi, ' ') + .replace(/<\\/(p|div|li|blockquote|h[1-6])>/gi, ' ') + .replace(/<[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/&#(?:(\\d+)|x([0-9a-fA-F]+));/g, (_, dec, hex) => { + try { return String.fromCodePoint(dec !== undefined ? Number(dec) : parseInt(hex, 16)); } catch { return ''; } + }) + .replace(/\\s+/g, ' ') + .trim(); + const posts = data?.post_stream?.posts || []; + return posts.slice(0, \${{ args.limit }}).map(p => ({ + author: p.username, + content: strip(p.cooked).slice(0, 200), + likes: p.like_count, + created_at: toLocalTime(p.created_at), + })); +})() +` }, + { map: { + author: '${{ item.author }}', + content: '${{ item.content }}', + likes: '${{ item.likes }}', + created_at: '${{ item.created_at }}', + } }, + ], +}); diff --git a/clis/linux-do/topic.yaml b/clis/linux-do/topic.yaml deleted file mode 100644 index 79b335f5..00000000 --- a/clis/linux-do/topic.yaml +++ /dev/null @@ -1,62 +0,0 @@ -site: linux-do -name: topic -description: linux.do 帖子首页摘要和回复(首屏) -domain: linux.do -strategy: cookie -browser: true - -args: - id: - positional: true - type: int - required: true - description: Topic ID - limit: - type: int - default: 20 - description: Number of posts - -pipeline: - - navigate: https://linux.do - - - evaluate: | - (async () => { - const toLocalTime = (utcStr) => { - if (!utcStr) return ''; - const date = new Date(utcStr); - return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString(); - }; - const res = await fetch('/t/${{ args.id }}.json', { credentials: 'include' }); - if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do'); - let data; - try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); } - const strip = (html) => (html || '') - .replace(//gi, ' ') - .replace(/<\/(p|div|li|blockquote|h[1-6])>/gi, ' ') - .replace(/<[^>]+>/g, '') - .replace(/ /g, ' ') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/&#(?:(\d+)|x([0-9a-fA-F]+));/g, (_, dec, hex) => { - try { return String.fromCodePoint(dec !== undefined ? Number(dec) : parseInt(hex, 16)); } catch { return ''; } - }) - .replace(/\s+/g, ' ') - .trim(); - const posts = data?.post_stream?.posts || []; - return posts.slice(0, ${{ args.limit }}).map(p => ({ - author: p.username, - content: strip(p.cooked).slice(0, 200), - likes: p.like_count, - created_at: toLocalTime(p.created_at), - })); - })() - - - map: - author: ${{ item.author }} - content: ${{ item.content }} - likes: ${{ item.likes }} - created_at: ${{ item.created_at }} - -columns: [author, content, likes, created_at] diff --git a/clis/linux-do/user-posts.ts b/clis/linux-do/user-posts.ts new file mode 100644 index 00000000..89d28498 --- /dev/null +++ b/clis/linux-do/user-posts.ts @@ -0,0 +1,62 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'linux-do', + name: 'user-posts', + description: 'linux.do 用户的帖子', + domain: 'linux.do', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'username', required: true, positional: true, help: 'Username' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of posts' }, + ], + columns: ['index', 'topic_user', 'topic', 'reply', 'time', 'url'], + pipeline: [ + { navigate: 'https://linux.do' }, + { evaluate: `(async () => { + const username = \${{ args.username | json }}; + const toLocalTime = (utcStr) => { + if (!utcStr) return ''; + const date = new Date(utcStr); + return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString(); + }; + const strip = (html) => (html || '') + .replace(//gi, ' ') + .replace(/<\\/(p|div|li|blockquote|h[1-6])>/gi, ' ') + .replace(/<[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/&#(?:(\\d+)|x([0-9a-fA-F]+));/g, (_, dec, hex) => { + try { return String.fromCodePoint(dec !== undefined ? Number(dec) : parseInt(hex, 16)); } catch { return ''; } + }) + .replace(/\\s+/g, ' ') + .trim(); + const limit = \${{ args.limit | default(20) }}; + const res = await fetch('/user_actions.json?username=' + encodeURIComponent(username) + '&filter=5&offset=0&limit=' + limit, { credentials: 'include' }); + if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do'); + let data; + try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); } + const actions = data?.user_actions || []; + return actions.slice(0, limit).map(a => ({ + author: a.acting_username || a.username || '', + title: a.title || '', + content: strip(a.excerpt).slice(0, 200), + created_at: toLocalTime(a.created_at), + url: 'https://linux.do/t/topic/' + a.topic_id + '/' + a.post_number, + })); +})() +` }, + { map: { + index: '${{ index + 1 }}', + topic_user: '${{ item.author }}', + topic: '${{ item.title }}', + reply: '${{ item.content }}', + time: '${{ item.created_at }}', + url: '${{ item.url }}', + } }, + ], +}); diff --git a/clis/linux-do/user-posts.yaml b/clis/linux-do/user-posts.yaml deleted file mode 100644 index cb553f23..00000000 --- a/clis/linux-do/user-posts.yaml +++ /dev/null @@ -1,67 +0,0 @@ -site: linux-do -name: user-posts -description: linux.do 用户的帖子 -domain: linux.do -strategy: cookie -browser: true - -args: - username: - positional: true - type: str - required: true - description: Username - limit: - type: int - default: 20 - description: Number of posts - -pipeline: - - navigate: https://linux.do - - - evaluate: | - (async () => { - const username = ${{ args.username | json }}; - const toLocalTime = (utcStr) => { - if (!utcStr) return ''; - const date = new Date(utcStr); - return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString(); - }; - const strip = (html) => (html || '') - .replace(//gi, ' ') - .replace(/<\/(p|div|li|blockquote|h[1-6])>/gi, ' ') - .replace(/<[^>]+>/g, '') - .replace(/ /g, ' ') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/&#(?:(\d+)|x([0-9a-fA-F]+));/g, (_, dec, hex) => { - try { return String.fromCodePoint(dec !== undefined ? Number(dec) : parseInt(hex, 16)); } catch { return ''; } - }) - .replace(/\s+/g, ' ') - .trim(); - const limit = ${{ args.limit | default(20) }}; - const res = await fetch('/user_actions.json?username=' + encodeURIComponent(username) + '&filter=5&offset=0&limit=' + limit, { credentials: 'include' }); - if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do'); - let data; - try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); } - const actions = data?.user_actions || []; - return actions.slice(0, limit).map(a => ({ - author: a.acting_username || a.username || '', - title: a.title || '', - content: strip(a.excerpt).slice(0, 200), - created_at: toLocalTime(a.created_at), - url: 'https://linux.do/t/topic/' + a.topic_id + '/' + a.post_number, - })); - })() - - - map: - index: ${{ index + 1 }} - topic_user: ${{ item.author }} - topic: ${{ item.title }} - reply: ${{ item.content }} - time: ${{ item.created_at }} - url: ${{ item.url }} - -columns: [index, topic_user, topic, reply, time, url] diff --git a/clis/linux-do/user-topics.ts b/clis/linux-do/user-topics.ts new file mode 100644 index 00000000..65a6f1c9 --- /dev/null +++ b/clis/linux-do/user-topics.ts @@ -0,0 +1,49 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'linux-do', + name: 'user-topics', + description: 'linux.do 用户创建的话题', + domain: 'linux.do', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'username', required: true, positional: true, help: 'Username' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of topics' }, + ], + columns: ['rank', 'title', 'replies', 'created_at', 'likes', 'views', 'url'], + pipeline: [ + { navigate: 'https://linux.do' }, + { evaluate: `(async () => { + const username = \${{ args.username | json }}; + const toLocalTime = (utcStr) => { + if (!utcStr) return ''; + const date = new Date(utcStr); + return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString(); + }; + const res = await fetch('/topics/created-by/' + encodeURIComponent(username) + '.json', { credentials: 'include' }); + if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do'); + let data; + try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); } + const topics = data?.topic_list?.topics || []; + return topics.slice(0, \${{ args.limit }}).map(t => ({ + title: t.fancy_title || t.title || '', + replies: t.posts_count || 0, + created_at: toLocalTime(t.created_at), + likes: t.like_count || 0, + views: t.views || 0, + url: 'https://linux.do/t/topic/' + t.id, + })); +})() +` }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + replies: '${{ item.replies }}', + created_at: '${{ item.created_at }}', + likes: '${{ item.likes }}', + views: '${{ item.views }}', + url: '${{ item.url }}', + } }, + ], +}); diff --git a/clis/linux-do/user-topics.yaml b/clis/linux-do/user-topics.yaml deleted file mode 100644 index 7373d010..00000000 --- a/clis/linux-do/user-topics.yaml +++ /dev/null @@ -1,54 +0,0 @@ -site: linux-do -name: user-topics -description: linux.do 用户创建的话题 -domain: linux.do -strategy: cookie -browser: true - -args: - username: - positional: true - type: str - required: true - description: Username - limit: - type: int - default: 20 - description: Number of topics - -pipeline: - - navigate: https://linux.do - - - evaluate: | - (async () => { - const username = ${{ args.username | json }}; - const toLocalTime = (utcStr) => { - if (!utcStr) return ''; - const date = new Date(utcStr); - return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString(); - }; - const res = await fetch('/topics/created-by/' + encodeURIComponent(username) + '.json', { credentials: 'include' }); - if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do'); - let data; - try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); } - const topics = data?.topic_list?.topics || []; - return topics.slice(0, ${{ args.limit }}).map(t => ({ - title: t.fancy_title || t.title || '', - replies: t.posts_count || 0, - created_at: toLocalTime(t.created_at), - likes: t.like_count || 0, - views: t.views || 0, - url: 'https://linux.do/t/topic/' + t.id, - })); - })() - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - replies: ${{ item.replies }} - created_at: ${{ item.created_at }} - likes: ${{ item.likes }} - views: ${{ item.views }} - url: ${{ item.url }} - -columns: [rank, title, replies, created_at, likes, views, url] diff --git a/clis/lobsters/active.ts b/clis/lobsters/active.ts new file mode 100644 index 00000000..7dfd467c --- /dev/null +++ b/clis/lobsters/active.ts @@ -0,0 +1,27 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'lobsters', + name: 'active', + description: 'Lobste.rs most active discussions', + domain: 'lobste.rs', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of stories' }, + ], + columns: ['rank', 'title', 'score', 'author', 'comments', 'tags'], + pipeline: [ + { fetch: { url: 'https://lobste.rs/active.json' } }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + score: '${{ item.score }}', + author: '${{ item.submitter_user }}', + comments: '${{ item.comment_count }}', + tags: `\${{ item.tags | join(', ') }}`, + url: '${{ item.comments_url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/lobsters/active.yaml b/clis/lobsters/active.yaml deleted file mode 100644 index 3a8e8249..00000000 --- a/clis/lobsters/active.yaml +++ /dev/null @@ -1,29 +0,0 @@ -site: lobsters -name: active -description: Lobste.rs most active discussions -domain: lobste.rs -strategy: public -browser: false - -args: - limit: - type: int - default: 20 - description: Number of stories - -pipeline: - - fetch: - url: https://lobste.rs/active.json - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - score: ${{ item.score }} - author: ${{ item.submitter_user }} - comments: ${{ item.comment_count }} - tags: ${{ item.tags | join(', ') }} - url: ${{ item.comments_url }} - - - limit: ${{ args.limit }} - -columns: [rank, title, score, author, comments, tags] diff --git a/clis/lobsters/hot.ts b/clis/lobsters/hot.ts new file mode 100644 index 00000000..2523436d --- /dev/null +++ b/clis/lobsters/hot.ts @@ -0,0 +1,27 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'lobsters', + name: 'hot', + description: 'Lobste.rs hottest stories', + domain: 'lobste.rs', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of stories' }, + ], + columns: ['rank', 'title', 'score', 'author', 'comments', 'tags'], + pipeline: [ + { fetch: { url: 'https://lobste.rs/hottest.json' } }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + score: '${{ item.score }}', + author: '${{ item.submitter_user }}', + comments: '${{ item.comment_count }}', + tags: `\${{ item.tags | join(', ') }}`, + url: '${{ item.comments_url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/lobsters/hot.yaml b/clis/lobsters/hot.yaml deleted file mode 100644 index 010a59cf..00000000 --- a/clis/lobsters/hot.yaml +++ /dev/null @@ -1,29 +0,0 @@ -site: lobsters -name: hot -description: Lobste.rs hottest stories -domain: lobste.rs -strategy: public -browser: false - -args: - limit: - type: int - default: 20 - description: Number of stories - -pipeline: - - fetch: - url: https://lobste.rs/hottest.json - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - score: ${{ item.score }} - author: ${{ item.submitter_user }} - comments: ${{ item.comment_count }} - tags: ${{ item.tags | join(', ') }} - url: ${{ item.comments_url }} - - - limit: ${{ args.limit }} - -columns: [rank, title, score, author, comments, tags] diff --git a/clis/lobsters/newest.ts b/clis/lobsters/newest.ts new file mode 100644 index 00000000..4f0ee781 --- /dev/null +++ b/clis/lobsters/newest.ts @@ -0,0 +1,27 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'lobsters', + name: 'newest', + description: 'Lobste.rs newest stories', + domain: 'lobste.rs', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of stories' }, + ], + columns: ['rank', 'title', 'score', 'author', 'comments', 'tags'], + pipeline: [ + { fetch: { url: 'https://lobste.rs/newest.json' } }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + score: '${{ item.score }}', + author: '${{ item.submitter_user }}', + comments: '${{ item.comment_count }}', + tags: `\${{ item.tags | join(', ') }}`, + url: '${{ item.comments_url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/lobsters/newest.yaml b/clis/lobsters/newest.yaml deleted file mode 100644 index 24deaa6f..00000000 --- a/clis/lobsters/newest.yaml +++ /dev/null @@ -1,29 +0,0 @@ -site: lobsters -name: newest -description: Lobste.rs newest stories -domain: lobste.rs -strategy: public -browser: false - -args: - limit: - type: int - default: 20 - description: Number of stories - -pipeline: - - fetch: - url: https://lobste.rs/newest.json - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - score: ${{ item.score }} - author: ${{ item.submitter_user }} - comments: ${{ item.comment_count }} - tags: ${{ item.tags | join(', ') }} - url: ${{ item.comments_url }} - - - limit: ${{ args.limit }} - -columns: [rank, title, score, author, comments, tags] diff --git a/clis/lobsters/tag.ts b/clis/lobsters/tag.ts new file mode 100644 index 00000000..1a8b0a22 --- /dev/null +++ b/clis/lobsters/tag.ts @@ -0,0 +1,33 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'lobsters', + name: 'tag', + description: 'Lobste.rs stories by tag', + domain: 'lobste.rs', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { + name: 'tag', + required: true, + positional: true, + help: 'Tag name (e.g. programming, rust, security, ai)', + }, + { name: 'limit', type: 'int', default: 20, help: 'Number of stories' }, + ], + columns: ['rank', 'title', 'score', 'author', 'comments', 'tags'], + pipeline: [ + { fetch: { url: 'https://lobste.rs/t/${{ args.tag }}.json' } }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + score: '${{ item.score }}', + author: '${{ item.submitter_user }}', + comments: '${{ item.comment_count }}', + tags: `\${{ item.tags | join(', ') }}`, + url: '${{ item.comments_url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/lobsters/tag.yaml b/clis/lobsters/tag.yaml deleted file mode 100644 index 7dff7d41..00000000 --- a/clis/lobsters/tag.yaml +++ /dev/null @@ -1,34 +0,0 @@ -site: lobsters -name: tag -description: Lobste.rs stories by tag -domain: lobste.rs -strategy: public -browser: false - -args: - tag: - type: str - required: true - positional: true - description: "Tag name (e.g. programming, rust, security, ai)" - limit: - type: int - default: 20 - description: Number of stories - -pipeline: - - fetch: - url: https://lobste.rs/t/${{ args.tag }}.json - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - score: ${{ item.score }} - author: ${{ item.submitter_user }} - comments: ${{ item.comment_count }} - tags: ${{ item.tags | join(', ') }} - url: ${{ item.comments_url }} - - - limit: ${{ args.limit }} - -columns: [rank, title, score, author, comments, tags] diff --git a/clis/pixiv/detail.ts b/clis/pixiv/detail.ts new file mode 100644 index 00000000..ae2feeab --- /dev/null +++ b/clis/pixiv/detail.ts @@ -0,0 +1,59 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'pixiv', + name: 'detail', + description: 'View illustration details (tags, stats, URLs)', + domain: 'www.pixiv.net', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'id', required: true, positional: true, help: 'Illustration ID' }, + ], + columns: [ + 'illust_id', + 'title', + 'author', + 'type', + 'pages', + 'bookmarks', + 'likes', + 'views', + 'tags', + 'created', + 'url', + ], + pipeline: [ + { navigate: 'https://www.pixiv.net' }, + { evaluate: `(async () => { + const id = \${{ args.id | json }}; + const res = await fetch( + 'https://www.pixiv.net/ajax/illust/' + id, + { credentials: 'include' } + ); + if (!res.ok) { + if (res.status === 401 || res.status === 403) throw new Error('Authentication required — please log in to Pixiv in Chrome'); + if (res.status === 404) throw new Error('Illustration not found: ' + id); + throw new Error('Pixiv request failed (HTTP ' + res.status + ')'); + } + const data = await res.json(); + const b = data?.body; + if (!b) throw new Error('Illustration not found'); + return [{ + illust_id: b.illustId, + title: b.illustTitle, + author: b.userName, + user_id: b.userId, + type: b.illustType === 0 ? 'illust' : b.illustType === 1 ? 'manga' : b.illustType === 2 ? 'ugoira' : String(b.illustType), + pages: b.pageCount, + bookmarks: b.bookmarkCount, + likes: b.likeCount, + views: b.viewCount, + tags: (b.tags?.tags || []).map(t => t.tag).join(', '), + created: b.createDate?.split('T')[0] || '', + url: 'https://www.pixiv.net/artworks/' + b.illustId + }]; +})() +` }, + ], +}); diff --git a/clis/pixiv/detail.yaml b/clis/pixiv/detail.yaml deleted file mode 100644 index d1d6ca5c..00000000 --- a/clis/pixiv/detail.yaml +++ /dev/null @@ -1,49 +0,0 @@ -site: pixiv -name: detail -description: View illustration details (tags, stats, URLs) -domain: www.pixiv.net -strategy: cookie -browser: true - -args: - id: - type: str - required: true - positional: true - description: Illustration ID - -pipeline: - - navigate: https://www.pixiv.net - - - evaluate: | - (async () => { - const id = ${{ args.id | json }}; - const res = await fetch( - 'https://www.pixiv.net/ajax/illust/' + id, - { credentials: 'include' } - ); - if (!res.ok) { - if (res.status === 401 || res.status === 403) throw new Error('Authentication required — please log in to Pixiv in Chrome'); - if (res.status === 404) throw new Error('Illustration not found: ' + id); - throw new Error('Pixiv request failed (HTTP ' + res.status + ')'); - } - const data = await res.json(); - const b = data?.body; - if (!b) throw new Error('Illustration not found'); - return [{ - illust_id: b.illustId, - title: b.illustTitle, - author: b.userName, - user_id: b.userId, - type: b.illustType === 0 ? 'illust' : b.illustType === 1 ? 'manga' : b.illustType === 2 ? 'ugoira' : String(b.illustType), - pages: b.pageCount, - bookmarks: b.bookmarkCount, - likes: b.likeCount, - views: b.viewCount, - tags: (b.tags?.tags || []).map(t => t.tag).join(', '), - created: b.createDate?.split('T')[0] || '', - url: 'https://www.pixiv.net/artworks/' + b.illustId - }]; - })() - -columns: [illust_id, title, author, type, pages, bookmarks, likes, views, tags, created, url] diff --git a/clis/pixiv/ranking.ts b/clis/pixiv/ranking.ts new file mode 100644 index 00000000..41179357 --- /dev/null +++ b/clis/pixiv/ranking.ts @@ -0,0 +1,60 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'pixiv', + name: 'ranking', + description: 'Pixiv illustration rankings (daily/weekly/monthly)', + domain: 'www.pixiv.net', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { + name: 'mode', + default: 'daily', + help: 'Ranking mode', + choices: [ + 'daily', + 'weekly', + 'monthly', + 'rookie', + 'original', + 'male', + 'female', + 'daily_r18', + 'weekly_r18', + ], + }, + { name: 'page', type: 'int', default: 1, help: 'Page number' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of results' }, + ], + columns: ['rank', 'title', 'author', 'illust_id', 'pages', 'bookmarks'], + pipeline: [ + { navigate: 'https://www.pixiv.net' }, + { evaluate: `(async () => { + const mode = \${{ args.mode | json }}; + const page = \${{ args.page | json }}; + const limit = \${{ args.limit | json }}; + const res = await fetch( + 'https://www.pixiv.net/ranking.php?mode=' + mode + '&p=' + page + '&format=json', + { credentials: 'include' } + ); + if (!res.ok) { + if (res.status === 401 || res.status === 403) throw new Error('Authentication required — please log in to Pixiv in Chrome'); + throw new Error('Pixiv request failed (HTTP ' + res.status + ')'); + } + const data = await res.json(); + const items = (data?.contents || []).slice(0, limit); + return items.map((item, i) => ({ + rank: item.rank, + title: item.title, + author: item.user_name, + user_id: item.user_id, + illust_id: item.illust_id, + pages: item.illust_page_count, + bookmarks: item.illust_bookmark_count, + url: 'https://www.pixiv.net/artworks/' + item.illust_id + })); +})() +` }, + ], +}); diff --git a/clis/pixiv/ranking.yaml b/clis/pixiv/ranking.yaml deleted file mode 100644 index 42893f06..00000000 --- a/clis/pixiv/ranking.yaml +++ /dev/null @@ -1,53 +0,0 @@ -site: pixiv -name: ranking -description: Pixiv illustration rankings (daily/weekly/monthly) -domain: www.pixiv.net -strategy: cookie -browser: true - -args: - mode: - type: str - default: daily - description: Ranking mode - choices: [daily, weekly, monthly, rookie, original, male, female, daily_r18, weekly_r18] - page: - type: int - default: 1 - description: Page number - limit: - type: int - default: 20 - description: Number of results - -pipeline: - - navigate: https://www.pixiv.net - - - evaluate: | - (async () => { - const mode = ${{ args.mode | json }}; - const page = ${{ args.page | json }}; - const limit = ${{ args.limit | json }}; - const res = await fetch( - 'https://www.pixiv.net/ranking.php?mode=' + mode + '&p=' + page + '&format=json', - { credentials: 'include' } - ); - if (!res.ok) { - if (res.status === 401 || res.status === 403) throw new Error('Authentication required — please log in to Pixiv in Chrome'); - throw new Error('Pixiv request failed (HTTP ' + res.status + ')'); - } - const data = await res.json(); - const items = (data?.contents || []).slice(0, limit); - return items.map((item, i) => ({ - rank: item.rank, - title: item.title, - author: item.user_name, - user_id: item.user_id, - illust_id: item.illust_id, - pages: item.illust_page_count, - bookmarks: item.illust_bookmark_count, - url: 'https://www.pixiv.net/artworks/' + item.illust_id - })); - })() - -columns: [rank, title, author, illust_id, pages, bookmarks] diff --git a/clis/pixiv/user.ts b/clis/pixiv/user.ts new file mode 100644 index 00000000..9726792f --- /dev/null +++ b/clis/pixiv/user.ts @@ -0,0 +1,53 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'pixiv', + name: 'user', + description: 'View Pixiv artist profile', + domain: 'www.pixiv.net', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'uid', required: true, positional: true, help: 'Pixiv user ID' }, + ], + columns: [ + 'user_id', + 'name', + 'premium', + 'following', + 'illusts', + 'manga', + 'novels', + 'comment', + ], + pipeline: [ + { navigate: 'https://www.pixiv.net' }, + { evaluate: `(async () => { + const uid = \${{ args.uid | json }}; + const res = await fetch( + 'https://www.pixiv.net/ajax/user/' + uid + '?full=1', + { credentials: 'include' } + ); + if (!res.ok) { + if (res.status === 401 || res.status === 403) throw new Error('Authentication required — please log in to Pixiv in Chrome'); + if (res.status === 404) throw new Error('User not found: ' + uid); + throw new Error('Pixiv request failed (HTTP ' + res.status + ')'); + } + const data = await res.json(); + const b = data?.body; + if (!b) throw new Error('User not found'); + return [{ + user_id: uid, + name: b.name, + premium: b.premium ? 'Yes' : 'No', + following: b.following, + illusts: typeof b.illusts === 'object' ? Object.keys(b.illusts).length : (b.illusts || 0), + manga: typeof b.manga === 'object' ? Object.keys(b.manga).length : (b.manga || 0), + novels: typeof b.novels === 'object' ? Object.keys(b.novels).length : (b.novels || 0), + comment: (b.comment || '').slice(0, 80), + url: 'https://www.pixiv.net/users/' + uid + }]; +})() +` }, + ], +}); diff --git a/clis/pixiv/user.yaml b/clis/pixiv/user.yaml deleted file mode 100644 index d2551fe6..00000000 --- a/clis/pixiv/user.yaml +++ /dev/null @@ -1,46 +0,0 @@ -site: pixiv -name: user -description: View Pixiv artist profile -domain: www.pixiv.net -strategy: cookie -browser: true - -args: - uid: - type: str - required: true - positional: true - description: Pixiv user ID - -pipeline: - - navigate: https://www.pixiv.net - - - evaluate: | - (async () => { - const uid = ${{ args.uid | json }}; - const res = await fetch( - 'https://www.pixiv.net/ajax/user/' + uid + '?full=1', - { credentials: 'include' } - ); - if (!res.ok) { - if (res.status === 401 || res.status === 403) throw new Error('Authentication required — please log in to Pixiv in Chrome'); - if (res.status === 404) throw new Error('User not found: ' + uid); - throw new Error('Pixiv request failed (HTTP ' + res.status + ')'); - } - const data = await res.json(); - const b = data?.body; - if (!b) throw new Error('User not found'); - return [{ - user_id: uid, - name: b.name, - premium: b.premium ? 'Yes' : 'No', - following: b.following, - illusts: typeof b.illusts === 'object' ? Object.keys(b.illusts).length : (b.illusts || 0), - manga: typeof b.manga === 'object' ? Object.keys(b.manga).length : (b.manga || 0), - novels: typeof b.novels === 'object' ? Object.keys(b.novels).length : (b.novels || 0), - comment: (b.comment || '').slice(0, 80), - url: 'https://www.pixiv.net/users/' + uid - }]; - })() - -columns: [user_id, name, premium, following, illusts, manga, novels, comment] diff --git a/clis/reddit/frontpage.ts b/clis/reddit/frontpage.ts new file mode 100644 index 00000000..87e4c7b0 --- /dev/null +++ b/clis/reddit/frontpage.ts @@ -0,0 +1,32 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'reddit', + name: 'frontpage', + description: 'Reddit Frontpage / r/all', + domain: 'reddit.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'limit', type: 'int', default: 15 }, + ], + columns: ['title', 'subreddit', 'author', 'upvotes', 'comments', 'url'], + pipeline: [ + { navigate: 'https://www.reddit.com' }, + { evaluate: `(async () => { + const res = await fetch('/r/all.json?limit=\${{ args.limit }}', { credentials: 'include' }); + const j = await res.json(); + return j?.data?.children || []; +})() +` }, + { map: { + title: '${{ item.data.title }}', + subreddit: '${{ item.data.subreddit_name_prefixed }}', + author: '${{ item.data.author }}', + upvotes: '${{ item.data.score }}', + comments: '${{ item.data.num_comments }}', + url: 'https://www.reddit.com${{ item.data.permalink }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/reddit/frontpage.yaml b/clis/reddit/frontpage.yaml deleted file mode 100644 index 32df6ace..00000000 --- a/clis/reddit/frontpage.yaml +++ /dev/null @@ -1,30 +0,0 @@ -site: reddit -name: frontpage -description: Reddit Frontpage / r/all -domain: reddit.com -strategy: cookie -browser: true - -args: - limit: - type: int - default: 15 - -columns: [title, subreddit, author, upvotes, comments, url] - -pipeline: - - navigate: https://www.reddit.com - - evaluate: | - (async () => { - const res = await fetch('/r/all.json?limit=${{ args.limit }}', { credentials: 'include' }); - const j = await res.json(); - return j?.data?.children || []; - })() - - map: - title: ${{ item.data.title }} - subreddit: ${{ item.data.subreddit_name_prefixed }} - author: ${{ item.data.author }} - upvotes: ${{ item.data.score }} - comments: ${{ item.data.num_comments }} - url: https://www.reddit.com${{ item.data.permalink }} - - limit: ${{ args.limit }} diff --git a/clis/reddit/hot.ts b/clis/reddit/hot.ts new file mode 100644 index 00000000..4b4477aa --- /dev/null +++ b/clis/reddit/hot.ts @@ -0,0 +1,46 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'reddit', + name: 'hot', + description: 'Reddit 热门帖子', + domain: 'www.reddit.com', + args: [ + { + name: 'subreddit', + default: '', + help: 'Subreddit name (e.g. programming). Empty for frontpage', + }, + { name: 'limit', type: 'int', default: 20, help: 'Number of posts' }, + ], + columns: ['rank', 'title', 'subreddit', 'score', 'comments'], + pipeline: [ + { navigate: 'https://www.reddit.com' }, + { evaluate: `(async () => { + const sub = \${{ args.subreddit | json }}; + const path = sub ? '/r/' + sub + '/hot.json' : '/hot.json'; + const limit = \${{ args.limit }}; + const res = await fetch(path + '?limit=' + limit + '&raw_json=1', { + credentials: 'include' + }); + const d = await res.json(); + return (d?.data?.children || []).map(c => ({ + title: c.data.title, + subreddit: c.data.subreddit_name_prefixed, + score: c.data.score, + comments: c.data.num_comments, + author: c.data.author, + url: 'https://www.reddit.com' + c.data.permalink, + })); +})() +` }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + subreddit: '${{ item.subreddit }}', + score: '${{ item.score }}', + comments: '${{ item.comments }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/reddit/hot.yaml b/clis/reddit/hot.yaml deleted file mode 100644 index b1b53a11..00000000 --- a/clis/reddit/hot.yaml +++ /dev/null @@ -1,47 +0,0 @@ -site: reddit -name: hot -description: Reddit 热门帖子 -domain: www.reddit.com - -args: - subreddit: - type: str - default: "" - description: "Subreddit name (e.g. programming). Empty for frontpage" - limit: - type: int - default: 20 - description: Number of posts - -pipeline: - - navigate: https://www.reddit.com - - - evaluate: | - (async () => { - const sub = ${{ args.subreddit | json }}; - const path = sub ? '/r/' + sub + '/hot.json' : '/hot.json'; - const limit = ${{ args.limit }}; - const res = await fetch(path + '?limit=' + limit + '&raw_json=1', { - credentials: 'include' - }); - const d = await res.json(); - return (d?.data?.children || []).map(c => ({ - title: c.data.title, - subreddit: c.data.subreddit_name_prefixed, - score: c.data.score, - comments: c.data.num_comments, - author: c.data.author, - url: 'https://www.reddit.com' + c.data.permalink, - })); - })() - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - subreddit: ${{ item.subreddit }} - score: ${{ item.score }} - comments: ${{ item.comments }} - - - limit: ${{ args.limit }} - -columns: [rank, title, subreddit, score, comments] diff --git a/clis/reddit/popular.ts b/clis/reddit/popular.ts new file mode 100644 index 00000000..75f8774c --- /dev/null +++ b/clis/reddit/popular.ts @@ -0,0 +1,42 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'reddit', + name: 'popular', + description: 'Reddit Popular posts (/r/popular)', + domain: 'reddit.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'limit', type: 'int', default: 20 }, + ], + columns: ['rank', 'title', 'subreddit', 'score', 'comments', 'url'], + pipeline: [ + { navigate: 'https://www.reddit.com' }, + { evaluate: `(async () => { + const limit = \${{ args.limit }}; + const res = await fetch('/r/popular.json?limit=' + limit + '&raw_json=1', { + credentials: 'include' + }); + const d = await res.json(); + return (d?.data?.children || []).map(c => ({ + title: c.data.title, + subreddit: c.data.subreddit_name_prefixed, + score: c.data.score, + comments: c.data.num_comments, + author: c.data.author, + url: 'https://www.reddit.com' + c.data.permalink, + })); +})() +` }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + subreddit: '${{ item.subreddit }}', + score: '${{ item.score }}', + comments: '${{ item.comments }}', + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/reddit/popular.yaml b/clis/reddit/popular.yaml deleted file mode 100644 index 032c4c40..00000000 --- a/clis/reddit/popular.yaml +++ /dev/null @@ -1,40 +0,0 @@ -site: reddit -name: popular -description: Reddit Popular posts (/r/popular) -domain: reddit.com -strategy: cookie -browser: true - -args: - limit: - type: int - default: 20 - -columns: [rank, title, subreddit, score, comments, url] - -pipeline: - - navigate: https://www.reddit.com - - evaluate: | - (async () => { - const limit = ${{ args.limit }}; - const res = await fetch('/r/popular.json?limit=' + limit + '&raw_json=1', { - credentials: 'include' - }); - const d = await res.json(); - return (d?.data?.children || []).map(c => ({ - title: c.data.title, - subreddit: c.data.subreddit_name_prefixed, - score: c.data.score, - comments: c.data.num_comments, - author: c.data.author, - url: 'https://www.reddit.com' + c.data.permalink, - })); - })() - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - subreddit: ${{ item.subreddit }} - score: ${{ item.score }} - comments: ${{ item.comments }} - url: ${{ item.url }} - - limit: ${{ args.limit }} diff --git a/clis/reddit/search.ts b/clis/reddit/search.ts new file mode 100644 index 00000000..2e0c1232 --- /dev/null +++ b/clis/reddit/search.ts @@ -0,0 +1,66 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'reddit', + name: 'search', + description: 'Search Reddit Posts', + domain: 'reddit.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'query', type: 'string', required: true, positional: true }, + { + name: 'subreddit', + type: 'string', + default: '', + help: 'Search within a specific subreddit', + }, + { + name: 'sort', + type: 'string', + default: 'relevance', + help: 'Sort order: relevance, hot, top, new, comments', + }, + { + name: 'time', + type: 'string', + default: 'all', + help: 'Time filter: hour, day, week, month, year, all', + }, + { name: 'limit', type: 'int', default: 15 }, + ], + columns: ['title', 'subreddit', 'author', 'score', 'comments', 'url'], + pipeline: [ + { navigate: 'https://www.reddit.com' }, + { evaluate: `(async () => { + const q = encodeURIComponent(\${{ args.query | json }}); + const sub = \${{ args.subreddit | json }}; + const sort = \${{ args.sort | json }}; + const time = \${{ args.time | json }}; + const limit = \${{ args.limit }}; + const basePath = sub ? '/r/' + sub + '/search.json' : '/search.json'; + const params = 'q=' + q + '&sort=' + sort + '&t=' + time + '&limit=' + limit + + '&restrict_sr=' + (sub ? 'on' : 'off') + '&raw_json=1'; + const res = await fetch(basePath + '?' + params, { credentials: 'include' }); + const d = await res.json(); + return (d?.data?.children || []).map(c => ({ + title: c.data.title, + subreddit: c.data.subreddit_name_prefixed, + author: c.data.author, + score: c.data.score, + comments: c.data.num_comments, + url: 'https://www.reddit.com' + c.data.permalink, + })); +})() +` }, + { map: { + title: '${{ item.title }}', + subreddit: '${{ item.subreddit }}', + author: '${{ item.author }}', + score: '${{ item.score }}', + comments: '${{ item.comments }}', + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/reddit/search.yaml b/clis/reddit/search.yaml deleted file mode 100644 index 8519f189..00000000 --- a/clis/reddit/search.yaml +++ /dev/null @@ -1,61 +0,0 @@ -site: reddit -name: search -description: Search Reddit Posts -domain: reddit.com -strategy: cookie -browser: true - -args: - query: - positional: true - type: string - required: true - subreddit: - type: string - default: "" - description: "Search within a specific subreddit" - sort: - type: string - default: relevance - description: "Sort order: relevance, hot, top, new, comments" - time: - type: string - default: all - description: "Time filter: hour, day, week, month, year, all" - limit: - type: int - default: 15 - -columns: [title, subreddit, author, score, comments, url] - -pipeline: - - navigate: https://www.reddit.com - - evaluate: | - (async () => { - const q = encodeURIComponent(${{ args.query | json }}); - const sub = ${{ args.subreddit | json }}; - const sort = ${{ args.sort | json }}; - const time = ${{ args.time | json }}; - const limit = ${{ args.limit }}; - const basePath = sub ? '/r/' + sub + '/search.json' : '/search.json'; - const params = 'q=' + q + '&sort=' + sort + '&t=' + time + '&limit=' + limit - + '&restrict_sr=' + (sub ? 'on' : 'off') + '&raw_json=1'; - const res = await fetch(basePath + '?' + params, { credentials: 'include' }); - const d = await res.json(); - return (d?.data?.children || []).map(c => ({ - title: c.data.title, - subreddit: c.data.subreddit_name_prefixed, - author: c.data.author, - score: c.data.score, - comments: c.data.num_comments, - url: 'https://www.reddit.com' + c.data.permalink, - })); - })() - - map: - title: ${{ item.title }} - subreddit: ${{ item.subreddit }} - author: ${{ item.author }} - score: ${{ item.score }} - comments: ${{ item.comments }} - url: ${{ item.url }} - - limit: ${{ args.limit }} diff --git a/clis/reddit/subreddit.ts b/clis/reddit/subreddit.ts new file mode 100644 index 00000000..b27567db --- /dev/null +++ b/clis/reddit/subreddit.ts @@ -0,0 +1,53 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'reddit', + name: 'subreddit', + description: 'Get posts from a specific Subreddit', + domain: 'reddit.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'name', type: 'string', required: true, positional: true }, + { + name: 'sort', + type: 'string', + default: 'hot', + help: 'Sorting method: hot, new, top, rising, controversial', + }, + { + name: 'time', + type: 'string', + default: 'all', + help: 'Time filter for top/controversial: hour, day, week, month, year, all', + }, + { name: 'limit', type: 'int', default: 15 }, + ], + columns: ['title', 'author', 'upvotes', 'comments', 'url'], + pipeline: [ + { navigate: 'https://www.reddit.com' }, + { evaluate: `(async () => { + let sub = \${{ args.name | json }}; + if (sub.startsWith('r/')) sub = sub.slice(2); + const sort = \${{ args.sort | json }}; + const time = \${{ args.time | json }}; + const limit = \${{ args.limit }}; + let url = '/r/' + sub + '/' + sort + '.json?limit=' + limit + '&raw_json=1'; + if ((sort === 'top' || sort === 'controversial') && time) { + url += '&t=' + time; + } + const res = await fetch(url, { credentials: 'include' }); + const j = await res.json(); + return j?.data?.children || []; +})() +` }, + { map: { + title: '${{ item.data.title }}', + author: '${{ item.data.author }}', + upvotes: '${{ item.data.score }}', + comments: '${{ item.data.num_comments }}', + url: 'https://www.reddit.com${{ item.data.permalink }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/reddit/subreddit.yaml b/clis/reddit/subreddit.yaml deleted file mode 100644 index 8569fc52..00000000 --- a/clis/reddit/subreddit.yaml +++ /dev/null @@ -1,50 +0,0 @@ -site: reddit -name: subreddit -description: Get posts from a specific Subreddit -domain: reddit.com -strategy: cookie -browser: true - -args: - name: - type: string - positional: true - required: true - sort: - type: string - default: hot - description: "Sorting method: hot, new, top, rising, controversial" - time: - type: string - default: all - description: "Time filter for top/controversial: hour, day, week, month, year, all" - limit: - type: int - default: 15 - -columns: [title, author, upvotes, comments, url] - -pipeline: - - navigate: https://www.reddit.com - - evaluate: | - (async () => { - let sub = ${{ args.name | json }}; - if (sub.startsWith('r/')) sub = sub.slice(2); - const sort = ${{ args.sort | json }}; - const time = ${{ args.time | json }}; - const limit = ${{ args.limit }}; - let url = '/r/' + sub + '/' + sort + '.json?limit=' + limit + '&raw_json=1'; - if ((sort === 'top' || sort === 'controversial') && time) { - url += '&t=' + time; - } - const res = await fetch(url, { credentials: 'include' }); - const j = await res.json(); - return j?.data?.children || []; - })() - - map: - title: ${{ item.data.title }} - author: ${{ item.data.author }} - upvotes: ${{ item.data.score }} - comments: ${{ item.data.num_comments }} - url: https://www.reddit.com${{ item.data.permalink }} - - limit: ${{ args.limit }} diff --git a/clis/reddit/user-comments.ts b/clis/reddit/user-comments.ts new file mode 100644 index 00000000..76477afb --- /dev/null +++ b/clis/reddit/user-comments.ts @@ -0,0 +1,45 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'reddit', + name: 'user-comments', + description: `View a Reddit user's comment history`, + domain: 'reddit.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'username', type: 'string', required: true, positional: true }, + { name: 'limit', type: 'int', default: 15 }, + ], + columns: ['subreddit', 'score', 'body', 'url'], + pipeline: [ + { navigate: 'https://www.reddit.com' }, + { evaluate: `(async () => { + const username = \${{ args.username | json }}; + const name = username.startsWith('u/') ? username.slice(2) : username; + const limit = \${{ args.limit }}; + const res = await fetch('/user/' + name + '/comments.json?limit=' + limit + '&raw_json=1', { + credentials: 'include' + }); + const d = await res.json(); + return (d?.data?.children || []).map(c => { + let body = c.data.body || ''; + if (body.length > 300) body = body.slice(0, 300) + '...'; + return { + subreddit: c.data.subreddit_name_prefixed, + score: c.data.score, + body: body, + url: 'https://www.reddit.com' + c.data.permalink, + }; + }); +})() +` }, + { map: { + subreddit: '${{ item.subreddit }}', + score: '${{ item.score }}', + body: '${{ item.body }}', + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/reddit/user-comments.yaml b/clis/reddit/user-comments.yaml deleted file mode 100644 index ae6c5ccb..00000000 --- a/clis/reddit/user-comments.yaml +++ /dev/null @@ -1,46 +0,0 @@ -site: reddit -name: user-comments -description: View a Reddit user's comment history -domain: reddit.com -strategy: cookie -browser: true - -args: - username: - positional: true - type: string - required: true - limit: - type: int - default: 15 - -columns: [subreddit, score, body, url] - -pipeline: - - navigate: https://www.reddit.com - - evaluate: | - (async () => { - const username = ${{ args.username | json }}; - const name = username.startsWith('u/') ? username.slice(2) : username; - const limit = ${{ args.limit }}; - const res = await fetch('/user/' + name + '/comments.json?limit=' + limit + '&raw_json=1', { - credentials: 'include' - }); - const d = await res.json(); - return (d?.data?.children || []).map(c => { - let body = c.data.body || ''; - if (body.length > 300) body = body.slice(0, 300) + '...'; - return { - subreddit: c.data.subreddit_name_prefixed, - score: c.data.score, - body: body, - url: 'https://www.reddit.com' + c.data.permalink, - }; - }); - })() - - map: - subreddit: ${{ item.subreddit }} - score: ${{ item.score }} - body: ${{ item.body }} - url: ${{ item.url }} - - limit: ${{ args.limit }} diff --git a/clis/reddit/user-posts.ts b/clis/reddit/user-posts.ts new file mode 100644 index 00000000..1a7d0dbd --- /dev/null +++ b/clis/reddit/user-posts.ts @@ -0,0 +1,43 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'reddit', + name: 'user-posts', + description: `View a Reddit user's submitted posts`, + domain: 'reddit.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'username', type: 'string', required: true, positional: true }, + { name: 'limit', type: 'int', default: 15 }, + ], + columns: ['title', 'subreddit', 'score', 'comments', 'url'], + pipeline: [ + { navigate: 'https://www.reddit.com' }, + { evaluate: `(async () => { + const username = \${{ args.username | json }}; + const name = username.startsWith('u/') ? username.slice(2) : username; + const limit = \${{ args.limit }}; + const res = await fetch('/user/' + name + '/submitted.json?limit=' + limit + '&raw_json=1', { + credentials: 'include' + }); + const d = await res.json(); + return (d?.data?.children || []).map(c => ({ + title: c.data.title, + subreddit: c.data.subreddit_name_prefixed, + score: c.data.score, + comments: c.data.num_comments, + url: 'https://www.reddit.com' + c.data.permalink, + })); +})() +` }, + { map: { + title: '${{ item.title }}', + subreddit: '${{ item.subreddit }}', + score: '${{ item.score }}', + comments: '${{ item.comments }}', + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/reddit/user-posts.yaml b/clis/reddit/user-posts.yaml deleted file mode 100644 index 1b3eb91a..00000000 --- a/clis/reddit/user-posts.yaml +++ /dev/null @@ -1,44 +0,0 @@ -site: reddit -name: user-posts -description: View a Reddit user's submitted posts -domain: reddit.com -strategy: cookie -browser: true - -args: - username: - positional: true - type: string - required: true - limit: - type: int - default: 15 - -columns: [title, subreddit, score, comments, url] - -pipeline: - - navigate: https://www.reddit.com - - evaluate: | - (async () => { - const username = ${{ args.username | json }}; - const name = username.startsWith('u/') ? username.slice(2) : username; - const limit = ${{ args.limit }}; - const res = await fetch('/user/' + name + '/submitted.json?limit=' + limit + '&raw_json=1', { - credentials: 'include' - }); - const d = await res.json(); - return (d?.data?.children || []).map(c => ({ - title: c.data.title, - subreddit: c.data.subreddit_name_prefixed, - score: c.data.score, - comments: c.data.num_comments, - url: 'https://www.reddit.com' + c.data.permalink, - })); - })() - - map: - title: ${{ item.title }} - subreddit: ${{ item.subreddit }} - score: ${{ item.score }} - comments: ${{ item.comments }} - url: ${{ item.url }} - - limit: ${{ args.limit }} diff --git a/clis/reddit/user.ts b/clis/reddit/user.ts new file mode 100644 index 00000000..3e13dc89 --- /dev/null +++ b/clis/reddit/user.ts @@ -0,0 +1,38 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'reddit', + name: 'user', + description: 'View a Reddit user profile', + domain: 'reddit.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'username', type: 'string', required: true, positional: true }, + ], + columns: ['field', 'value'], + pipeline: [ + { navigate: 'https://www.reddit.com' }, + { evaluate: `(async () => { + const username = \${{ args.username | json }}; + const name = username.startsWith('u/') ? username.slice(2) : username; + const res = await fetch('/user/' + name + '/about.json?raw_json=1', { + credentials: 'include' + }); + const d = await res.json(); + const u = d?.data || d || {}; + const created = u.created_utc ? new Date(u.created_utc * 1000).toISOString().split('T')[0] : '-'; + return [ + { field: 'Username', value: 'u/' + (u.name || name) }, + { field: 'Post Karma', value: String(u.link_karma || 0) }, + { field: 'Comment Karma', value: String(u.comment_karma || 0) }, + { field: 'Total Karma', value: String(u.total_karma || (u.link_karma||0) + (u.comment_karma||0)) }, + { field: 'Account Created', value: created }, + { field: 'Gold', value: u.is_gold ? '⭐ Yes' : 'No' }, + { field: 'Verified', value: u.verified ? '✅ Yes' : 'No' }, + ]; +})() +` }, + { map: { field: '${{ item.field }}', value: '${{ item.value }}' } }, + ], +}); diff --git a/clis/reddit/user.yaml b/clis/reddit/user.yaml deleted file mode 100644 index 91d82b9a..00000000 --- a/clis/reddit/user.yaml +++ /dev/null @@ -1,40 +0,0 @@ -site: reddit -name: user -description: View a Reddit user profile -domain: reddit.com -strategy: cookie -browser: true - -args: - username: - positional: true - type: string - required: true - -columns: [field, value] - -pipeline: - - navigate: https://www.reddit.com - - evaluate: | - (async () => { - const username = ${{ args.username | json }}; - const name = username.startsWith('u/') ? username.slice(2) : username; - const res = await fetch('/user/' + name + '/about.json?raw_json=1', { - credentials: 'include' - }); - const d = await res.json(); - const u = d?.data || d || {}; - const created = u.created_utc ? new Date(u.created_utc * 1000).toISOString().split('T')[0] : '-'; - return [ - { field: 'Username', value: 'u/' + (u.name || name) }, - { field: 'Post Karma', value: String(u.link_karma || 0) }, - { field: 'Comment Karma', value: String(u.comment_karma || 0) }, - { field: 'Total Karma', value: String(u.total_karma || (u.link_karma||0) + (u.comment_karma||0)) }, - { field: 'Account Created', value: created }, - { field: 'Gold', value: u.is_gold ? '⭐ Yes' : 'No' }, - { field: 'Verified', value: u.verified ? '✅ Yes' : 'No' }, - ]; - })() - - map: - field: ${{ item.field }} - value: ${{ item.value }} diff --git a/clis/stackoverflow/bounties.ts b/clis/stackoverflow/bounties.ts new file mode 100644 index 00000000..92a6f0c7 --- /dev/null +++ b/clis/stackoverflow/bounties.ts @@ -0,0 +1,28 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'stackoverflow', + name: 'bounties', + description: 'Active bounties on Stack Overflow', + domain: 'stackoverflow.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 10, help: 'Max number of results' }, + ], + columns: ['bounty', 'title', 'score', 'answers', 'url'], + pipeline: [ + { fetch: { + url: 'https://api.stackexchange.com/2.3/questions/featured?order=desc&sort=activity&site=stackoverflow', + } }, + { select: 'items' }, + { map: { + title: '${{ item.title }}', + bounty: '${{ item.bounty_amount }}', + score: '${{ item.score }}', + answers: '${{ item.answer_count }}', + url: '${{ item.link }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/stackoverflow/bounties.yaml b/clis/stackoverflow/bounties.yaml deleted file mode 100644 index d30fe8df..00000000 --- a/clis/stackoverflow/bounties.yaml +++ /dev/null @@ -1,29 +0,0 @@ -site: stackoverflow -name: bounties -description: Active bounties on Stack Overflow -domain: stackoverflow.com -strategy: public -browser: false - -args: - limit: - type: int - default: 10 - description: Max number of results - -pipeline: - - fetch: - url: https://api.stackexchange.com/2.3/questions/featured?order=desc&sort=activity&site=stackoverflow - - - select: items - - - map: - title: "${{ item.title }}" - bounty: "${{ item.bounty_amount }}" - score: "${{ item.score }}" - answers: "${{ item.answer_count }}" - url: "${{ item.link }}" - - - limit: ${{ args.limit }} - -columns: [bounty, title, score, answers, url] diff --git a/clis/stackoverflow/hot.ts b/clis/stackoverflow/hot.ts new file mode 100644 index 00000000..45f29705 --- /dev/null +++ b/clis/stackoverflow/hot.ts @@ -0,0 +1,25 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'stackoverflow', + name: 'hot', + description: 'Hot Stack Overflow questions', + domain: 'stackoverflow.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 10, help: 'Max number of results' }, + ], + columns: ['title', 'score', 'answers', 'url'], + pipeline: [ + { fetch: { url: 'https://api.stackexchange.com/2.3/questions?order=desc&sort=hot&site=stackoverflow' } }, + { select: 'items' }, + { map: { + title: '${{ item.title }}', + score: '${{ item.score }}', + answers: '${{ item.answer_count }}', + url: '${{ item.link }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/stackoverflow/hot.yaml b/clis/stackoverflow/hot.yaml deleted file mode 100644 index a4e5d497..00000000 --- a/clis/stackoverflow/hot.yaml +++ /dev/null @@ -1,28 +0,0 @@ -site: stackoverflow -name: hot -description: Hot Stack Overflow questions -domain: stackoverflow.com -strategy: public -browser: false - -args: - limit: - type: int - default: 10 - description: Max number of results - -pipeline: - - fetch: - url: https://api.stackexchange.com/2.3/questions?order=desc&sort=hot&site=stackoverflow - - - select: items - - - map: - title: "${{ item.title }}" - score: "${{ item.score }}" - answers: "${{ item.answer_count }}" - url: "${{ item.link }}" - - - limit: ${{ args.limit }} - -columns: [title, score, answers, url] diff --git a/clis/stackoverflow/search.ts b/clis/stackoverflow/search.ts new file mode 100644 index 00000000..67307f64 --- /dev/null +++ b/clis/stackoverflow/search.ts @@ -0,0 +1,28 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'stackoverflow', + name: 'search', + description: 'Search Stack Overflow questions', + domain: 'stackoverflow.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'query', type: 'string', required: true, positional: true, help: 'Search query' }, + { name: 'limit', type: 'int', default: 10, help: 'Max number of results' }, + ], + columns: ['title', 'score', 'answers', 'url'], + pipeline: [ + { fetch: { + url: 'https://api.stackexchange.com/2.3/search/advanced?order=desc&sort=relevance&q=${{ args.query }}&site=stackoverflow', + } }, + { select: 'items' }, + { map: { + title: '${{ item.title }}', + score: '${{ item.score }}', + answers: '${{ item.answer_count }}', + url: '${{ item.link }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/stackoverflow/search.yaml b/clis/stackoverflow/search.yaml deleted file mode 100644 index f5841b1d..00000000 --- a/clis/stackoverflow/search.yaml +++ /dev/null @@ -1,33 +0,0 @@ -site: stackoverflow -name: search -description: Search Stack Overflow questions -domain: stackoverflow.com -strategy: public -browser: false - -args: - query: - positional: true - type: string - required: true - description: Search query - limit: - type: int - default: 10 - description: Max number of results - -pipeline: - - fetch: - url: https://api.stackexchange.com/2.3/search/advanced?order=desc&sort=relevance&q=${{ args.query }}&site=stackoverflow - - - select: items - - - map: - title: "${{ item.title }}" - score: "${{ item.score }}" - answers: "${{ item.answer_count }}" - url: "${{ item.link }}" - - - limit: ${{ args.limit }} - -columns: [title, score, answers, url] diff --git a/clis/stackoverflow/unanswered.ts b/clis/stackoverflow/unanswered.ts new file mode 100644 index 00000000..f56bed1e --- /dev/null +++ b/clis/stackoverflow/unanswered.ts @@ -0,0 +1,27 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'stackoverflow', + name: 'unanswered', + description: 'Top voted unanswered questions on Stack Overflow', + domain: 'stackoverflow.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 10, help: 'Max number of results' }, + ], + columns: ['title', 'score', 'answers', 'url'], + pipeline: [ + { fetch: { + url: 'https://api.stackexchange.com/2.3/questions/unanswered?order=desc&sort=votes&site=stackoverflow', + } }, + { select: 'items' }, + { map: { + title: '${{ item.title }}', + score: '${{ item.score }}', + answers: '${{ item.answer_count }}', + url: '${{ item.link }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/stackoverflow/unanswered.yaml b/clis/stackoverflow/unanswered.yaml deleted file mode 100644 index a21266fa..00000000 --- a/clis/stackoverflow/unanswered.yaml +++ /dev/null @@ -1,28 +0,0 @@ -site: stackoverflow -name: unanswered -description: Top voted unanswered questions on Stack Overflow -domain: stackoverflow.com -strategy: public -browser: false - -args: - limit: - type: int - default: 10 - description: Max number of results - -pipeline: - - fetch: - url: https://api.stackexchange.com/2.3/questions/unanswered?order=desc&sort=votes&site=stackoverflow - - - select: items - - - map: - title: "${{ item.title }}" - score: "${{ item.score }}" - answers: "${{ item.answer_count }}" - url: "${{ item.link }}" - - - limit: ${{ args.limit }} - -columns: [title, score, answers, url] diff --git a/clis/steam/top-sellers.ts b/clis/steam/top-sellers.ts new file mode 100644 index 00000000..d56cadba --- /dev/null +++ b/clis/steam/top-sellers.ts @@ -0,0 +1,26 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'steam', + name: 'top-sellers', + description: 'Steam top selling games', + domain: 'store.steampowered.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 10, help: 'Number of games' }, + ], + columns: ['rank', 'name', 'price', 'discount', 'url'], + pipeline: [ + { fetch: { url: 'https://store.steampowered.com/api/featuredcategories/' } }, + { select: 'top_sellers.items' }, + { map: { + rank: '${{ index + 1 }}', + name: '${{ item.name }}', + price: '${{ item.final_price }}', + discount: '${{ item.discount_percent }}', + url: 'https://store.steampowered.com/app/${{ item.id }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/steam/top-sellers.yaml b/clis/steam/top-sellers.yaml deleted file mode 100644 index 37f9a927..00000000 --- a/clis/steam/top-sellers.yaml +++ /dev/null @@ -1,29 +0,0 @@ -site: steam -name: top-sellers -description: Steam top selling games -domain: store.steampowered.com -strategy: public -browser: false - -args: - limit: - type: int - default: 10 - description: Number of games - -pipeline: - - fetch: - url: https://store.steampowered.com/api/featuredcategories/ - - - select: top_sellers.items - - - map: - rank: ${{ index + 1 }} - name: ${{ item.name }} - price: ${{ item.final_price }} - discount: ${{ item.discount_percent }} - url: https://store.steampowered.com/app/${{ item.id }} - - - limit: ${{ args.limit }} - -columns: [rank, name, price, discount, url] diff --git a/clis/tiktok/comment.ts b/clis/tiktok/comment.ts new file mode 100644 index 00000000..5dee3aa7 --- /dev/null +++ b/clis/tiktok/comment.ts @@ -0,0 +1,58 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'tiktok', + name: 'comment', + description: 'Comment on a TikTok video', + domain: 'www.tiktok.com', + args: [ + { name: 'url', required: true, positional: true, help: 'TikTok video URL' }, + { name: 'text', required: true, positional: true, help: 'Comment text' }, + ], + columns: ['status', 'url', 'text'], + pipeline: [ + { navigate: { url: '${{ args.url }}', settleMs: 6000 } }, + { evaluate: `(async () => { + const url = \${{ args.url | json }}; + const commentText = \${{ args.text | json }}; + const wait = (ms) => new Promise(r => setTimeout(r, ms)); + + // Click comment icon to expand comment section + const commentIcon = document.querySelector('[data-e2e="comment-icon"]'); + if (commentIcon) { + const cBtn = commentIcon.closest('button') || commentIcon.closest('[role="button"]') || commentIcon; + cBtn.click(); + await wait(3000); + } + + // Count existing comments for verification + const beforeCount = document.querySelectorAll('[data-e2e="comment-level-1"]').length; + + // Find comment input + const input = document.querySelector('[data-e2e="comment-input"] [contenteditable="true"]') || + document.querySelector('[contenteditable="true"]'); + if (!input) throw new Error('Comment input not found - make sure you are logged in'); + + input.focus(); + document.execCommand('insertText', false, commentText); + await wait(1000); + + // Click post button + const btns = Array.from(document.querySelectorAll('[data-e2e="comment-post"], button')); + const postBtn = btns.find(function(b) { + var t = b.textContent.trim(); + return t === 'Post' || t === '发布' || t === '发送'; + }); + if (!postBtn) throw new Error('Post button not found'); + postBtn.click(); + await wait(3000); + + // Verify comment was posted by checking if comment count increased + const afterCount = document.querySelectorAll('[data-e2e="comment-level-1"]').length; + const posted = afterCount > beforeCount; + + return [{ status: posted ? 'Commented' : 'Comment may have failed', url: url, text: commentText }]; +})() +` }, + ], +}); diff --git a/clis/tiktok/comment.yaml b/clis/tiktok/comment.yaml deleted file mode 100644 index af24da77..00000000 --- a/clis/tiktok/comment.yaml +++ /dev/null @@ -1,66 +0,0 @@ -site: tiktok -name: comment -description: Comment on a TikTok video -domain: www.tiktok.com - -args: - url: - type: str - required: true - positional: true - description: TikTok video URL - text: - positional: true - type: str - required: true - description: Comment text - -pipeline: - - navigate: - url: ${{ args.url }} - settleMs: 6000 - - - evaluate: | - (async () => { - const url = ${{ args.url | json }}; - const commentText = ${{ args.text | json }}; - const wait = (ms) => new Promise(r => setTimeout(r, ms)); - - // Click comment icon to expand comment section - const commentIcon = document.querySelector('[data-e2e="comment-icon"]'); - if (commentIcon) { - const cBtn = commentIcon.closest('button') || commentIcon.closest('[role="button"]') || commentIcon; - cBtn.click(); - await wait(3000); - } - - // Count existing comments for verification - const beforeCount = document.querySelectorAll('[data-e2e="comment-level-1"]').length; - - // Find comment input - const input = document.querySelector('[data-e2e="comment-input"] [contenteditable="true"]') || - document.querySelector('[contenteditable="true"]'); - if (!input) throw new Error('Comment input not found - make sure you are logged in'); - - input.focus(); - document.execCommand('insertText', false, commentText); - await wait(1000); - - // Click post button - const btns = Array.from(document.querySelectorAll('[data-e2e="comment-post"], button')); - const postBtn = btns.find(function(b) { - var t = b.textContent.trim(); - return t === 'Post' || t === '发布' || t === '发送'; - }); - if (!postBtn) throw new Error('Post button not found'); - postBtn.click(); - await wait(3000); - - // Verify comment was posted by checking if comment count increased - const afterCount = document.querySelectorAll('[data-e2e="comment-level-1"]').length; - const posted = afterCount > beforeCount; - - return [{ status: posted ? 'Commented' : 'Comment may have failed', url: url, text: commentText }]; - })() - -columns: [status, url, text] diff --git a/clis/tiktok/explore.ts b/clis/tiktok/explore.ts new file mode 100644 index 00000000..c01657f0 --- /dev/null +++ b/clis/tiktok/explore.ts @@ -0,0 +1,36 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'tiktok', + name: 'explore', + description: 'Get trending TikTok videos from explore page', + domain: 'www.tiktok.com', + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of videos' }, + ], + columns: ['rank', 'author', 'views', 'url'], + pipeline: [ + { navigate: { url: 'https://www.tiktok.com/explore', settleMs: 5000 } }, + { evaluate: `(() => { + const limit = \${{ args.limit }}; + const links = Array.from(document.querySelectorAll('a[href*="/video/"]')); + const seen = new Set(); + const results = []; + for (const a of links) { + const href = a.href; + if (seen.has(href)) continue; + seen.add(href); + const match = href.match(/@([^/]+)\\/video\\/(\\d+)/); + results.push({ + rank: results.length + 1, + author: match ? match[1] : '', + views: a.textContent.trim() || '-', + url: href, + }); + if (results.length >= limit) break; + } + return results; +})() +` }, + ], +}); diff --git a/clis/tiktok/explore.yaml b/clis/tiktok/explore.yaml deleted file mode 100644 index 3e3e9d4f..00000000 --- a/clis/tiktok/explore.yaml +++ /dev/null @@ -1,39 +0,0 @@ -site: tiktok -name: explore -description: Get trending TikTok videos from explore page -domain: www.tiktok.com - -args: - limit: - type: int - default: 20 - description: Number of videos - -pipeline: - - navigate: - url: https://www.tiktok.com/explore - settleMs: 5000 - - - evaluate: | - (() => { - const limit = ${{ args.limit }}; - const links = Array.from(document.querySelectorAll('a[href*="/video/"]')); - const seen = new Set(); - const results = []; - for (const a of links) { - const href = a.href; - if (seen.has(href)) continue; - seen.add(href); - const match = href.match(/@([^/]+)\/video\/(\d+)/); - results.push({ - rank: results.length + 1, - author: match ? match[1] : '', - views: a.textContent.trim() || '-', - url: href, - }); - if (results.length >= limit) break; - } - return results; - })() - -columns: [rank, author, views, url] diff --git a/clis/tiktok/follow.ts b/clis/tiktok/follow.ts new file mode 100644 index 00000000..33b1ddcf --- /dev/null +++ b/clis/tiktok/follow.ts @@ -0,0 +1,40 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'tiktok', + name: 'follow', + description: 'Follow a TikTok user', + domain: 'www.tiktok.com', + args: [ + { + name: 'username', + required: true, + positional: true, + help: 'TikTok username (without @)', + }, + ], + columns: ['status', 'username'], + pipeline: [ + { navigate: { url: 'https://www.tiktok.com/@${{ args.username }}', settleMs: 6000 } }, + { evaluate: `(async () => { + const username = \${{ args.username | json }}; + const buttons = Array.from(document.querySelectorAll('button, [role="button"]')); + const followBtn = buttons.find(function(b) { + var text = b.textContent.trim(); + return text === 'Follow' || text === '关注'; + }); + if (!followBtn) { + var isFollowing = buttons.some(function(b) { + var t = b.textContent.trim(); + return t === 'Following' || t === '已关注' || t === 'Friends' || t === '互关'; + }); + if (isFollowing) return [{ status: 'Already following', username: username }]; + return [{ status: 'Follow button not found', username: username }]; + } + followBtn.click(); + await new Promise(r => setTimeout(r, 2000)); + return [{ status: 'Followed', username: username }]; +})() +` }, + ], +}); diff --git a/clis/tiktok/follow.yaml b/clis/tiktok/follow.yaml deleted file mode 100644 index 0d155829..00000000 --- a/clis/tiktok/follow.yaml +++ /dev/null @@ -1,39 +0,0 @@ -site: tiktok -name: follow -description: Follow a TikTok user -domain: www.tiktok.com - -args: - username: - type: str - required: true - positional: true - description: TikTok username (without @) - -pipeline: - - navigate: - url: https://www.tiktok.com/@${{ args.username }} - settleMs: 6000 - - - evaluate: | - (async () => { - const username = ${{ args.username | json }}; - const buttons = Array.from(document.querySelectorAll('button, [role="button"]')); - const followBtn = buttons.find(function(b) { - var text = b.textContent.trim(); - return text === 'Follow' || text === '关注'; - }); - if (!followBtn) { - var isFollowing = buttons.some(function(b) { - var t = b.textContent.trim(); - return t === 'Following' || t === '已关注' || t === 'Friends' || t === '互关'; - }); - if (isFollowing) return [{ status: 'Already following', username: username }]; - return [{ status: 'Follow button not found', username: username }]; - } - followBtn.click(); - await new Promise(r => setTimeout(r, 2000)); - return [{ status: 'Followed', username: username }]; - })() - -columns: [status, username] diff --git a/clis/tiktok/following.ts b/clis/tiktok/following.ts new file mode 100644 index 00000000..0144b40b --- /dev/null +++ b/clis/tiktok/following.ts @@ -0,0 +1,43 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'tiktok', + name: 'following', + description: 'List accounts you follow on TikTok', + domain: 'www.tiktok.com', + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of accounts' }, + ], + columns: ['index', 'username', 'name'], + pipeline: [ + { navigate: { url: 'https://www.tiktok.com/following', settleMs: 5000 } }, + { evaluate: `(() => { + const limit = \${{ args.limit }}; + const links = Array.from(document.querySelectorAll('a[href*="/@"]')) + .filter(function(a) { + const text = a.textContent.trim(); + return text.length > 1 && text.length < 80 && + !text.includes('Profile') && !text.includes('More') && !text.includes('Upload'); + }); + + const seen = {}; + const results = []; + for (const a of links) { + const match = a.href.match(/@([^/]+)/); + const username = match ? match[1] : ''; + if (!username || seen[username]) continue; + seen[username] = true; + const raw = a.textContent.trim(); + const name = raw.replace(username, '').replace('@', '').trim(); + results.push({ + index: results.length + 1, + username: username, + name: name || username, + }); + if (results.length >= limit) break; + } + return results; +})() +` }, + ], +}); diff --git a/clis/tiktok/following.yaml b/clis/tiktok/following.yaml deleted file mode 100644 index 057eff5a..00000000 --- a/clis/tiktok/following.yaml +++ /dev/null @@ -1,46 +0,0 @@ -site: tiktok -name: following -description: List accounts you follow on TikTok -domain: www.tiktok.com - -args: - limit: - type: int - default: 20 - description: Number of accounts - -pipeline: - - navigate: - url: https://www.tiktok.com/following - settleMs: 5000 - - - evaluate: | - (() => { - const limit = ${{ args.limit }}; - const links = Array.from(document.querySelectorAll('a[href*="/@"]')) - .filter(function(a) { - const text = a.textContent.trim(); - return text.length > 1 && text.length < 80 && - !text.includes('Profile') && !text.includes('More') && !text.includes('Upload'); - }); - - const seen = {}; - const results = []; - for (const a of links) { - const match = a.href.match(/@([^/]+)/); - const username = match ? match[1] : ''; - if (!username || seen[username]) continue; - seen[username] = true; - const raw = a.textContent.trim(); - const name = raw.replace(username, '').replace('@', '').trim(); - results.push({ - index: results.length + 1, - username: username, - name: name || username, - }); - if (results.length >= limit) break; - } - return results; - })() - -columns: [index, username, name] diff --git a/clis/tiktok/friends.ts b/clis/tiktok/friends.ts new file mode 100644 index 00000000..5664b631 --- /dev/null +++ b/clis/tiktok/friends.ts @@ -0,0 +1,44 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'tiktok', + name: 'friends', + description: 'Get TikTok friend suggestions', + domain: 'www.tiktok.com', + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of suggestions' }, + ], + columns: ['index', 'username', 'name'], + pipeline: [ + { navigate: { url: 'https://www.tiktok.com/friends', settleMs: 5000 } }, + { evaluate: `(() => { + const limit = \${{ args.limit }}; + const links = Array.from(document.querySelectorAll('a[href*="/@"]')) + .filter(function(a) { + const text = a.textContent.trim(); + return text.length > 1 && text.length < 80 && + !text.includes('Profile') && !text.includes('More') && !text.includes('Upload'); + }); + + const seen = {}; + const results = []; + for (const a of links) { + const match = a.href.match(/@([^/]+)/); + const username = match ? match[1] : ''; + if (!username || seen[username]) continue; + seen[username] = true; + const raw = a.textContent.trim(); + const hasFollow = raw.includes('Follow'); + const name = raw.replace('Follow', '').replace(username, '').replace('@', '').trim(); + results.push({ + index: results.length + 1, + username: username, + name: name || username, + }); + if (results.length >= limit) break; + } + return results; +})() +` }, + ], +}); diff --git a/clis/tiktok/friends.yaml b/clis/tiktok/friends.yaml deleted file mode 100644 index fa517969..00000000 --- a/clis/tiktok/friends.yaml +++ /dev/null @@ -1,47 +0,0 @@ -site: tiktok -name: friends -description: Get TikTok friend suggestions -domain: www.tiktok.com - -args: - limit: - type: int - default: 20 - description: Number of suggestions - -pipeline: - - navigate: - url: https://www.tiktok.com/friends - settleMs: 5000 - - - evaluate: | - (() => { - const limit = ${{ args.limit }}; - const links = Array.from(document.querySelectorAll('a[href*="/@"]')) - .filter(function(a) { - const text = a.textContent.trim(); - return text.length > 1 && text.length < 80 && - !text.includes('Profile') && !text.includes('More') && !text.includes('Upload'); - }); - - const seen = {}; - const results = []; - for (const a of links) { - const match = a.href.match(/@([^/]+)/); - const username = match ? match[1] : ''; - if (!username || seen[username]) continue; - seen[username] = true; - const raw = a.textContent.trim(); - const hasFollow = raw.includes('Follow'); - const name = raw.replace('Follow', '').replace(username, '').replace('@', '').trim(); - results.push({ - index: results.length + 1, - username: username, - name: name || username, - }); - if (results.length >= limit) break; - } - return results; - })() - -columns: [index, username, name] diff --git a/clis/tiktok/like.ts b/clis/tiktok/like.ts new file mode 100644 index 00000000..50afe1ee --- /dev/null +++ b/clis/tiktok/like.ts @@ -0,0 +1,34 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'tiktok', + name: 'like', + description: 'Like a TikTok video', + domain: 'www.tiktok.com', + args: [ + { name: 'url', required: true, positional: true, help: 'TikTok video URL' }, + ], + columns: ['status', 'likes', 'url'], + pipeline: [ + { navigate: { url: '${{ args.url }}', settleMs: 6000 } }, + { evaluate: `(async () => { + const url = \${{ args.url | json }}; + const btn = document.querySelector('[data-e2e="like-icon"]'); + if (!btn) throw new Error('Like button not found - make sure you are logged in'); + const container = btn.closest('button') || btn.closest('[role="button"]') || btn; + const aria = (container.getAttribute('aria-label') || '').toLowerCase(); + const color = window.getComputedStyle(btn).color; + const isLiked = aria.includes('unlike') || aria.includes('取消点赞') || + (color && (color.includes('255, 65') || color.includes('fe2c55'))); + if (isLiked) { + const count = document.querySelector('[data-e2e="like-count"]'); + return [{ status: 'Already liked', likes: count ? count.textContent.trim() : '-', url: url }]; + } + container.click(); + await new Promise(r => setTimeout(r, 2000)); + const count = document.querySelector('[data-e2e="like-count"]'); + return [{ status: 'Liked', likes: count ? count.textContent.trim() : '-', url: url }]; +})() +` }, + ], +}); diff --git a/clis/tiktok/like.yaml b/clis/tiktok/like.yaml deleted file mode 100644 index e30b6ff0..00000000 --- a/clis/tiktok/like.yaml +++ /dev/null @@ -1,38 +0,0 @@ -site: tiktok -name: like -description: Like a TikTok video -domain: www.tiktok.com - -args: - url: - type: str - required: true - positional: true - description: TikTok video URL - -pipeline: - - navigate: - url: ${{ args.url }} - settleMs: 6000 - - - evaluate: | - (async () => { - const url = ${{ args.url | json }}; - const btn = document.querySelector('[data-e2e="like-icon"]'); - if (!btn) throw new Error('Like button not found - make sure you are logged in'); - const container = btn.closest('button') || btn.closest('[role="button"]') || btn; - const aria = (container.getAttribute('aria-label') || '').toLowerCase(); - const color = window.getComputedStyle(btn).color; - const isLiked = aria.includes('unlike') || aria.includes('取消点赞') || - (color && (color.includes('255, 65') || color.includes('fe2c55'))); - if (isLiked) { - const count = document.querySelector('[data-e2e="like-count"]'); - return [{ status: 'Already liked', likes: count ? count.textContent.trim() : '-', url: url }]; - } - container.click(); - await new Promise(r => setTimeout(r, 2000)); - const count = document.querySelector('[data-e2e="like-count"]'); - return [{ status: 'Liked', likes: count ? count.textContent.trim() : '-', url: url }]; - })() - -columns: [status, likes, url] diff --git a/clis/tiktok/live.ts b/clis/tiktok/live.ts new file mode 100644 index 00000000..82d8d820 --- /dev/null +++ b/clis/tiktok/live.ts @@ -0,0 +1,48 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'tiktok', + name: 'live', + description: 'Browse live streams on TikTok', + domain: 'www.tiktok.com', + args: [ + { name: 'limit', type: 'int', default: 10, help: 'Number of streams' }, + ], + columns: ['index', 'streamer', 'viewers', 'url'], + pipeline: [ + { navigate: { url: 'https://www.tiktok.com/live', settleMs: 5000 } }, + { evaluate: `(() => { + const limit = \${{ args.limit }}; + // Sidebar live list has structured data + const items = document.querySelectorAll('[data-e2e="live-side-nav-item"]'); + const sidebar = Array.from(items).slice(0, limit).map(function(el, i) { + const nameEl = el.querySelector('[data-e2e="live-side-nav-name"]'); + const countEl = el.querySelector('[data-e2e="person-count"]'); + const link = el.querySelector('a'); + return { + index: i + 1, + streamer: nameEl ? nameEl.textContent.trim() : '', + viewers: countEl ? countEl.textContent.trim() : '-', + url: link ? link.href : '', + }; + }); + + if (sidebar.length > 0) return sidebar; + + // Fallback: main content cards + const cards = document.querySelectorAll('[data-e2e="discover-list-live-card"]'); + return Array.from(cards).slice(0, limit).map(function(card, i) { + const text = card.textContent.trim().replace(/\\s+/g, ' '); + const link = card.querySelector('a[href*="/live"]'); + const viewerMatch = text.match(/(\\d[\\d,.]*)\\s*watching/); + return { + index: i + 1, + streamer: text.replace(/LIVE.*$/, '').trim().substring(0, 40), + viewers: viewerMatch ? viewerMatch[1] : '-', + url: link ? link.href : '', + }; + }); +})() +` }, + ], +}); diff --git a/clis/tiktok/live.yaml b/clis/tiktok/live.yaml deleted file mode 100644 index 09ee474f..00000000 --- a/clis/tiktok/live.yaml +++ /dev/null @@ -1,51 +0,0 @@ -site: tiktok -name: live -description: Browse live streams on TikTok -domain: www.tiktok.com - -args: - limit: - type: int - default: 10 - description: Number of streams - -pipeline: - - navigate: - url: https://www.tiktok.com/live - settleMs: 5000 - - - evaluate: | - (() => { - const limit = ${{ args.limit }}; - // Sidebar live list has structured data - const items = document.querySelectorAll('[data-e2e="live-side-nav-item"]'); - const sidebar = Array.from(items).slice(0, limit).map(function(el, i) { - const nameEl = el.querySelector('[data-e2e="live-side-nav-name"]'); - const countEl = el.querySelector('[data-e2e="person-count"]'); - const link = el.querySelector('a'); - return { - index: i + 1, - streamer: nameEl ? nameEl.textContent.trim() : '', - viewers: countEl ? countEl.textContent.trim() : '-', - url: link ? link.href : '', - }; - }); - - if (sidebar.length > 0) return sidebar; - - // Fallback: main content cards - const cards = document.querySelectorAll('[data-e2e="discover-list-live-card"]'); - return Array.from(cards).slice(0, limit).map(function(card, i) { - const text = card.textContent.trim().replace(/\s+/g, ' '); - const link = card.querySelector('a[href*="/live"]'); - const viewerMatch = text.match(/(\d[\d,.]*)\s*watching/); - return { - index: i + 1, - streamer: text.replace(/LIVE.*$/, '').trim().substring(0, 40), - viewers: viewerMatch ? viewerMatch[1] : '-', - url: link ? link.href : '', - }; - }); - })() - -columns: [index, streamer, viewers, url] diff --git a/clis/tiktok/notifications.ts b/clis/tiktok/notifications.ts new file mode 100644 index 00000000..90a62aea --- /dev/null +++ b/clis/tiktok/notifications.ts @@ -0,0 +1,50 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'tiktok', + name: 'notifications', + description: 'Get TikTok notifications (likes, comments, mentions, followers)', + domain: 'www.tiktok.com', + args: [ + { name: 'limit', type: 'int', default: 15, help: 'Number of notifications' }, + { + name: 'type', + default: 'all', + help: 'Notification type', + choices: ['all', 'likes', 'comments', 'mentions', 'followers'], + }, + ], + columns: ['index', 'text'], + pipeline: [ + { navigate: { url: 'https://www.tiktok.com/following', settleMs: 5000 } }, + { evaluate: `(async () => { + const limit = \${{ args.limit }}; + const type = \${{ args.type | json }}; + const wait = (ms) => new Promise(r => setTimeout(r, ms)); + + // Click inbox icon to open notifications panel + const inboxIcon = document.querySelector('[data-e2e="inbox-icon"]'); + if (inboxIcon) inboxIcon.click(); + await wait(1500); + + // Click specific tab if needed + if (type !== 'all') { + const tab = document.querySelector('[data-e2e="' + type + '"]'); + if (tab) { + tab.click(); + await wait(1500); + } + } + + const items = document.querySelectorAll('[data-e2e="inbox-list"] > div, [data-e2e="inbox-list"] [role="button"]'); + return Array.from(items) + .filter(el => el.textContent.trim().length > 5) + .slice(0, limit) + .map((el, i) => ({ + index: i + 1, + text: el.textContent.trim().replace(/\\s+/g, ' ').substring(0, 150), + })); +})() +` }, + ], +}); diff --git a/clis/tiktok/notifications.yaml b/clis/tiktok/notifications.yaml deleted file mode 100644 index 2c9045ab..00000000 --- a/clis/tiktok/notifications.yaml +++ /dev/null @@ -1,52 +0,0 @@ -site: tiktok -name: notifications -description: Get TikTok notifications (likes, comments, mentions, followers) -domain: www.tiktok.com - -args: - limit: - type: int - default: 15 - description: Number of notifications - type: - type: str - default: all - description: Notification type - choices: [all, likes, comments, mentions, followers] - -pipeline: - - navigate: - url: https://www.tiktok.com/following - settleMs: 5000 - - - evaluate: | - (async () => { - const limit = ${{ args.limit }}; - const type = ${{ args.type | json }}; - const wait = (ms) => new Promise(r => setTimeout(r, ms)); - - // Click inbox icon to open notifications panel - const inboxIcon = document.querySelector('[data-e2e="inbox-icon"]'); - if (inboxIcon) inboxIcon.click(); - await wait(1500); - - // Click specific tab if needed - if (type !== 'all') { - const tab = document.querySelector('[data-e2e="' + type + '"]'); - if (tab) { - tab.click(); - await wait(1500); - } - } - - const items = document.querySelectorAll('[data-e2e="inbox-list"] > div, [data-e2e="inbox-list"] [role="button"]'); - return Array.from(items) - .filter(el => el.textContent.trim().length > 5) - .slice(0, limit) - .map((el, i) => ({ - index: i + 1, - text: el.textContent.trim().replace(/\s+/g, ' ').substring(0, 150), - })); - })() - -columns: [index, text] diff --git a/clis/tiktok/profile.ts b/clis/tiktok/profile.ts new file mode 100644 index 00000000..86e8be97 --- /dev/null +++ b/clis/tiktok/profile.ts @@ -0,0 +1,55 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'tiktok', + name: 'profile', + description: 'Get TikTok user profile info', + domain: 'www.tiktok.com', + args: [ + { + name: 'username', + required: true, + positional: true, + help: 'TikTok username (without @)', + }, + ], + columns: [ + 'username', + 'name', + 'followers', + 'following', + 'likes', + 'videos', + 'verified', + 'bio', + ], + pipeline: [ + { navigate: { url: 'https://www.tiktok.com/explore', settleMs: 5000 } }, + { evaluate: `(async () => { + const username = \${{ args.username | json }}; + const res = await fetch('https://www.tiktok.com/@' + encodeURIComponent(username), { credentials: 'include' }); + if (!res.ok) throw new Error('User not found: ' + username); + const html = await res.text(); + const idx = html.indexOf('__UNIVERSAL_DATA_FOR_REHYDRATION__'); + if (idx === -1) throw new Error('Could not parse profile data'); + const start = html.indexOf('>', idx) + 1; + const end = html.indexOf('', start); + const data = JSON.parse(html.substring(start, end)); + const ud = data['__DEFAULT_SCOPE__'] && data['__DEFAULT_SCOPE__']['webapp.user-detail']; + const u = ud && ud.userInfo && ud.userInfo.user; + const s = ud && ud.userInfo && ud.userInfo.stats; + if (!u) throw new Error('User not found: ' + username); + return [{ + username: u.uniqueId || username, + name: u.nickname || '', + bio: (u.signature || '').replace(/\\n/g, ' ').substring(0, 120), + followers: s && s.followerCount || 0, + following: s && s.followingCount || 0, + likes: s && s.heartCount || 0, + videos: s && s.videoCount || 0, + verified: u.verified ? 'Yes' : 'No', + }]; +})() +` }, + ], +}); diff --git a/clis/tiktok/profile.yaml b/clis/tiktok/profile.yaml deleted file mode 100644 index 76978ea2..00000000 --- a/clis/tiktok/profile.yaml +++ /dev/null @@ -1,45 +0,0 @@ -site: tiktok -name: profile -description: Get TikTok user profile info -domain: www.tiktok.com - -args: - username: - type: str - required: true - positional: true - description: TikTok username (without @) - -pipeline: - - navigate: - url: https://www.tiktok.com/explore - settleMs: 5000 - - - evaluate: | - (async () => { - const username = ${{ args.username | json }}; - const res = await fetch('https://www.tiktok.com/@' + encodeURIComponent(username), { credentials: 'include' }); - if (!res.ok) throw new Error('User not found: ' + username); - const html = await res.text(); - const idx = html.indexOf('__UNIVERSAL_DATA_FOR_REHYDRATION__'); - if (idx === -1) throw new Error('Could not parse profile data'); - const start = html.indexOf('>', idx) + 1; - const end = html.indexOf('', start); - const data = JSON.parse(html.substring(start, end)); - const ud = data['__DEFAULT_SCOPE__'] && data['__DEFAULT_SCOPE__']['webapp.user-detail']; - const u = ud && ud.userInfo && ud.userInfo.user; - const s = ud && ud.userInfo && ud.userInfo.stats; - if (!u) throw new Error('User not found: ' + username); - return [{ - username: u.uniqueId || username, - name: u.nickname || '', - bio: (u.signature || '').replace(/\n/g, ' ').substring(0, 120), - followers: s && s.followerCount || 0, - following: s && s.followingCount || 0, - likes: s && s.heartCount || 0, - videos: s && s.videoCount || 0, - verified: u.verified ? 'Yes' : 'No', - }]; - })() - -columns: [username, name, followers, following, likes, videos, verified, bio] diff --git a/clis/tiktok/save.ts b/clis/tiktok/save.ts new file mode 100644 index 00000000..d75b32de --- /dev/null +++ b/clis/tiktok/save.ts @@ -0,0 +1,30 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'tiktok', + name: 'save', + description: 'Add a TikTok video to Favorites', + domain: 'www.tiktok.com', + args: [ + { name: 'url', required: true, positional: true, help: 'TikTok video URL' }, + ], + columns: ['status', 'url'], + pipeline: [ + { navigate: { url: '${{ args.url }}', settleMs: 6000 } }, + { evaluate: `(async () => { + const url = \${{ args.url | json }}; + const btn = document.querySelector('[data-e2e="bookmark-icon"]') || + document.querySelector('[data-e2e="collect-icon"]'); + if (!btn) throw new Error('Favorites button not found - make sure you are logged in'); + const container = btn.closest('button') || btn.closest('[role="button"]') || btn; + const aria = (container.getAttribute('aria-label') || '').toLowerCase(); + if (aria.includes('remove from favorites') || aria.includes('取消收藏')) { + return [{ status: 'Already in Favorites', url: url }]; + } + container.click(); + await new Promise(r => setTimeout(r, 2000)); + return [{ status: 'Added to Favorites', url: url }]; +})() +` }, + ], +}); diff --git a/clis/tiktok/save.yaml b/clis/tiktok/save.yaml deleted file mode 100644 index eb5c16c5..00000000 --- a/clis/tiktok/save.yaml +++ /dev/null @@ -1,34 +0,0 @@ -site: tiktok -name: save -description: Add a TikTok video to Favorites -domain: www.tiktok.com - -args: - url: - type: str - required: true - positional: true - description: TikTok video URL - -pipeline: - - navigate: - url: ${{ args.url }} - settleMs: 6000 - - - evaluate: | - (async () => { - const url = ${{ args.url | json }}; - const btn = document.querySelector('[data-e2e="bookmark-icon"]') || - document.querySelector('[data-e2e="collect-icon"]'); - if (!btn) throw new Error('Favorites button not found - make sure you are logged in'); - const container = btn.closest('button') || btn.closest('[role="button"]') || btn; - const aria = (container.getAttribute('aria-label') || '').toLowerCase(); - if (aria.includes('remove from favorites') || aria.includes('取消收藏')) { - return [{ status: 'Already in Favorites', url: url }]; - } - container.click(); - await new Promise(r => setTimeout(r, 2000)); - return [{ status: 'Added to Favorites', url: url }]; - })() - -columns: [status, url] diff --git a/clis/tiktok/search.ts b/clis/tiktok/search.ts new file mode 100644 index 00000000..0695a902 --- /dev/null +++ b/clis/tiktok/search.ts @@ -0,0 +1,40 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'tiktok', + name: 'search', + description: 'Search TikTok videos', + domain: 'www.tiktok.com', + args: [ + { name: 'query', required: true, positional: true, help: 'Search query' }, + { name: 'limit', type: 'int', default: 10, help: 'Number of results' }, + ], + columns: ['rank', 'desc', 'author', 'url', 'plays', 'likes', 'comments', 'shares'], + pipeline: [ + { navigate: { url: 'https://www.tiktok.com/explore', settleMs: 5000 } }, + { evaluate: `(async () => { + const query = \${{ args.query | json }}; + const limit = \${{ args.limit }}; + const res = await fetch('/api/search/general/full/?keyword=' + encodeURIComponent(query) + '&offset=0&count=' + limit + '&aid=1988', { credentials: 'include' }); + if (!res.ok) throw new Error('Search failed: HTTP ' + res.status); + const data = await res.json(); + const items = (data.data || []).filter(function(i) { return i.type === 1 && i.item; }); + return items.slice(0, limit).map(function(i, idx) { + var v = i.item; + var a = v.author || {}; + var s = v.stats || {}; + return { + rank: idx + 1, + desc: (v.desc || '').replace(/\\n/g, ' ').substring(0, 100), + author: a.uniqueId || '', + url: (a.uniqueId && v.id) ? 'https://www.tiktok.com/@' + a.uniqueId + '/video/' + v.id : '', + plays: s.playCount || 0, + likes: s.diggCount || 0, + comments: s.commentCount || 0, + shares: s.shareCount || 0, + }; + }); +})() +` }, + ], +}); diff --git a/clis/tiktok/search.yaml b/clis/tiktok/search.yaml deleted file mode 100644 index 84266940..00000000 --- a/clis/tiktok/search.yaml +++ /dev/null @@ -1,47 +0,0 @@ -site: tiktok -name: search -description: Search TikTok videos -domain: www.tiktok.com - -args: - query: - type: str - required: true - positional: true - description: Search query - limit: - type: int - default: 10 - description: Number of results - -pipeline: - - navigate: - url: https://www.tiktok.com/explore - settleMs: 5000 - - - evaluate: | - (async () => { - const query = ${{ args.query | json }}; - const limit = ${{ args.limit }}; - const res = await fetch('/api/search/general/full/?keyword=' + encodeURIComponent(query) + '&offset=0&count=' + limit + '&aid=1988', { credentials: 'include' }); - if (!res.ok) throw new Error('Search failed: HTTP ' + res.status); - const data = await res.json(); - const items = (data.data || []).filter(function(i) { return i.type === 1 && i.item; }); - return items.slice(0, limit).map(function(i, idx) { - var v = i.item; - var a = v.author || {}; - var s = v.stats || {}; - return { - rank: idx + 1, - desc: (v.desc || '').replace(/\n/g, ' ').substring(0, 100), - author: a.uniqueId || '', - url: (a.uniqueId && v.id) ? 'https://www.tiktok.com/@' + a.uniqueId + '/video/' + v.id : '', - plays: s.playCount || 0, - likes: s.diggCount || 0, - comments: s.commentCount || 0, - shares: s.shareCount || 0, - }; - }); - })() - -columns: [rank, desc, author, url, plays, likes, comments, shares] diff --git a/clis/tiktok/unfollow.ts b/clis/tiktok/unfollow.ts new file mode 100644 index 00000000..4febeb2c --- /dev/null +++ b/clis/tiktok/unfollow.ts @@ -0,0 +1,45 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'tiktok', + name: 'unfollow', + description: 'Unfollow a TikTok user', + domain: 'www.tiktok.com', + args: [ + { + name: 'username', + required: true, + positional: true, + help: 'TikTok username (without @)', + }, + ], + columns: ['status', 'username'], + pipeline: [ + { navigate: { url: 'https://www.tiktok.com/@${{ args.username }}', settleMs: 6000 } }, + { evaluate: `(async () => { + const username = \${{ args.username | json }}; + const buttons = Array.from(document.querySelectorAll('button, [role="button"]')); + const followingBtn = buttons.find(function(b) { + var text = b.textContent.trim(); + return text === 'Following' || text === '已关注' || text === 'Friends' || text === '互关'; + }); + if (!followingBtn) { + return [{ status: 'Not following this user', username: username }]; + } + followingBtn.click(); + await new Promise(r => setTimeout(r, 2000)); + // Confirm unfollow if dialog appears + var allBtns = Array.from(document.querySelectorAll('button')); + var confirm = allBtns.find(function(b) { + var t = b.textContent.trim(); + return t === 'Unfollow' || t === '取消关注'; + }); + if (confirm) { + confirm.click(); + await new Promise(r => setTimeout(r, 1500)); + } + return [{ status: 'Unfollowed', username: username }]; +})() +` }, + ], +}); diff --git a/clis/tiktok/unfollow.yaml b/clis/tiktok/unfollow.yaml deleted file mode 100644 index ca9a6ae7..00000000 --- a/clis/tiktok/unfollow.yaml +++ /dev/null @@ -1,44 +0,0 @@ -site: tiktok -name: unfollow -description: Unfollow a TikTok user -domain: www.tiktok.com - -args: - username: - type: str - required: true - positional: true - description: TikTok username (without @) - -pipeline: - - navigate: - url: https://www.tiktok.com/@${{ args.username }} - settleMs: 6000 - - - evaluate: | - (async () => { - const username = ${{ args.username | json }}; - const buttons = Array.from(document.querySelectorAll('button, [role="button"]')); - const followingBtn = buttons.find(function(b) { - var text = b.textContent.trim(); - return text === 'Following' || text === '已关注' || text === 'Friends' || text === '互关'; - }); - if (!followingBtn) { - return [{ status: 'Not following this user', username: username }]; - } - followingBtn.click(); - await new Promise(r => setTimeout(r, 2000)); - // Confirm unfollow if dialog appears - var allBtns = Array.from(document.querySelectorAll('button')); - var confirm = allBtns.find(function(b) { - var t = b.textContent.trim(); - return t === 'Unfollow' || t === '取消关注'; - }); - if (confirm) { - confirm.click(); - await new Promise(r => setTimeout(r, 1500)); - } - return [{ status: 'Unfollowed', username: username }]; - })() - -columns: [status, username] diff --git a/clis/tiktok/unlike.ts b/clis/tiktok/unlike.ts new file mode 100644 index 00000000..1eaa5e9f --- /dev/null +++ b/clis/tiktok/unlike.ts @@ -0,0 +1,34 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'tiktok', + name: 'unlike', + description: 'Unlike a TikTok video', + domain: 'www.tiktok.com', + args: [ + { name: 'url', required: true, positional: true, help: 'TikTok video URL' }, + ], + columns: ['status', 'likes', 'url'], + pipeline: [ + { navigate: { url: '${{ args.url }}', settleMs: 6000 } }, + { evaluate: `(async () => { + const url = \${{ args.url | json }}; + const btn = document.querySelector('[data-e2e="like-icon"]'); + if (!btn) throw new Error('Like button not found - make sure you are logged in'); + const container = btn.closest('button') || btn.closest('[role="button"]') || btn; + const aria = (container.getAttribute('aria-label') || '').toLowerCase(); + const color = window.getComputedStyle(btn).color; + const isLiked = aria.includes('unlike') || aria.includes('取消点赞') || + (color && (color.includes('255, 65') || color.includes('fe2c55'))); + if (!isLiked) { + const count = document.querySelector('[data-e2e="like-count"]'); + return [{ status: 'Not liked', likes: count ? count.textContent.trim() : '-', url: url }]; + } + container.click(); + await new Promise(r => setTimeout(r, 2000)); + const count = document.querySelector('[data-e2e="like-count"]'); + return [{ status: 'Unliked', likes: count ? count.textContent.trim() : '-', url: url }]; +})() +` }, + ], +}); diff --git a/clis/tiktok/unlike.yaml b/clis/tiktok/unlike.yaml deleted file mode 100644 index 9ffcdf63..00000000 --- a/clis/tiktok/unlike.yaml +++ /dev/null @@ -1,38 +0,0 @@ -site: tiktok -name: unlike -description: Unlike a TikTok video -domain: www.tiktok.com - -args: - url: - type: str - required: true - positional: true - description: TikTok video URL - -pipeline: - - navigate: - url: ${{ args.url }} - settleMs: 6000 - - - evaluate: | - (async () => { - const url = ${{ args.url | json }}; - const btn = document.querySelector('[data-e2e="like-icon"]'); - if (!btn) throw new Error('Like button not found - make sure you are logged in'); - const container = btn.closest('button') || btn.closest('[role="button"]') || btn; - const aria = (container.getAttribute('aria-label') || '').toLowerCase(); - const color = window.getComputedStyle(btn).color; - const isLiked = aria.includes('unlike') || aria.includes('取消点赞') || - (color && (color.includes('255, 65') || color.includes('fe2c55'))); - if (!isLiked) { - const count = document.querySelector('[data-e2e="like-count"]'); - return [{ status: 'Not liked', likes: count ? count.textContent.trim() : '-', url: url }]; - } - container.click(); - await new Promise(r => setTimeout(r, 2000)); - const count = document.querySelector('[data-e2e="like-count"]'); - return [{ status: 'Unliked', likes: count ? count.textContent.trim() : '-', url: url }]; - })() - -columns: [status, likes, url] diff --git a/clis/tiktok/unsave.ts b/clis/tiktok/unsave.ts new file mode 100644 index 00000000..c30f4318 --- /dev/null +++ b/clis/tiktok/unsave.ts @@ -0,0 +1,32 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'tiktok', + name: 'unsave', + description: 'Remove a TikTok video from Favorites', + domain: 'www.tiktok.com', + args: [ + { name: 'url', required: true, positional: true, help: 'TikTok video URL' }, + ], + columns: ['status', 'url'], + pipeline: [ + { navigate: { url: '${{ args.url }}', settleMs: 6000 } }, + { evaluate: `(async () => { + const url = \${{ args.url | json }}; + const btn = document.querySelector('[data-e2e="bookmark-icon"]') || + document.querySelector('[data-e2e="collect-icon"]'); + if (!btn) throw new Error('Favorites button not found - make sure you are logged in'); + const container = btn.closest('button') || btn.closest('[role="button"]') || btn; + const aria = (container.getAttribute('aria-label') || '').toLowerCase(); + if (aria.includes('add to favorites') || aria.includes('收藏')) { + if (!aria.includes('remove') && !aria.includes('取消')) { + return [{ status: 'Not in Favorites', url: url }]; + } + } + container.click(); + await new Promise(r => setTimeout(r, 2000)); + return [{ status: 'Removed from Favorites', url: url }]; +})() +` }, + ], +}); diff --git a/clis/tiktok/unsave.yaml b/clis/tiktok/unsave.yaml deleted file mode 100644 index 099ce3a9..00000000 --- a/clis/tiktok/unsave.yaml +++ /dev/null @@ -1,36 +0,0 @@ -site: tiktok -name: unsave -description: Remove a TikTok video from Favorites -domain: www.tiktok.com - -args: - url: - type: str - required: true - positional: true - description: TikTok video URL - -pipeline: - - navigate: - url: ${{ args.url }} - settleMs: 6000 - - - evaluate: | - (async () => { - const url = ${{ args.url | json }}; - const btn = document.querySelector('[data-e2e="bookmark-icon"]') || - document.querySelector('[data-e2e="collect-icon"]'); - if (!btn) throw new Error('Favorites button not found - make sure you are logged in'); - const container = btn.closest('button') || btn.closest('[role="button"]') || btn; - const aria = (container.getAttribute('aria-label') || '').toLowerCase(); - if (aria.includes('add to favorites') || aria.includes('收藏')) { - if (!aria.includes('remove') && !aria.includes('取消')) { - return [{ status: 'Not in Favorites', url: url }]; - } - } - container.click(); - await new Promise(r => setTimeout(r, 2000)); - return [{ status: 'Removed from Favorites', url: url }]; - })() - -columns: [status, url] diff --git a/clis/tiktok/user.ts b/clis/tiktok/user.ts new file mode 100644 index 00000000..4365d122 --- /dev/null +++ b/clis/tiktok/user.ts @@ -0,0 +1,42 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'tiktok', + name: 'user', + description: 'Get recent videos from a TikTok user', + domain: 'www.tiktok.com', + args: [ + { + name: 'username', + required: true, + positional: true, + help: 'TikTok username (without @)', + }, + { name: 'limit', type: 'int', default: 10, help: 'Number of videos' }, + ], + columns: ['index', 'views', 'url'], + pipeline: [ + { navigate: { url: 'https://www.tiktok.com/@${{ args.username }}', settleMs: 6000 } }, + { evaluate: `(() => { + const limit = \${{ args.limit }}; + const username = \${{ args.username | json }}; + const links = Array.from(document.querySelectorAll('a[href*="/video/"]')); + const seen = {}; + const results = []; + for (const a of links) { + const href = a.href; + if (seen[href]) continue; + seen[href] = true; + results.push({ + index: results.length + 1, + views: a.textContent.trim() || '-', + url: href, + }); + if (results.length >= limit) break; + } + if (results.length === 0) throw new Error('No videos found for @' + username); + return results; +})() +` }, + ], +}); diff --git a/clis/tiktok/user.yaml b/clis/tiktok/user.yaml deleted file mode 100644 index 410f1a75..00000000 --- a/clis/tiktok/user.yaml +++ /dev/null @@ -1,44 +0,0 @@ -site: tiktok -name: user -description: Get recent videos from a TikTok user -domain: www.tiktok.com - -args: - username: - type: str - required: true - positional: true - description: TikTok username (without @) - limit: - type: int - default: 10 - description: Number of videos - -pipeline: - - navigate: - url: https://www.tiktok.com/@${{ args.username }} - settleMs: 6000 - - - evaluate: | - (() => { - const limit = ${{ args.limit }}; - const username = ${{ args.username | json }}; - const links = Array.from(document.querySelectorAll('a[href*="/video/"]')); - const seen = {}; - const results = []; - for (const a of links) { - const href = a.href; - if (seen[href]) continue; - seen[href] = true; - results.push({ - index: results.length + 1, - views: a.textContent.trim() || '-', - url: href, - }); - if (results.length >= limit) break; - } - if (results.length === 0) throw new Error('No videos found for @' + username); - return results; - })() - -columns: [index, views, url] diff --git a/clis/v2ex/hot.ts b/clis/v2ex/hot.ts new file mode 100644 index 00000000..3194219b --- /dev/null +++ b/clis/v2ex/hot.ts @@ -0,0 +1,26 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'v2ex', + name: 'hot', + description: 'V2EX 热门话题', + domain: 'www.v2ex.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of topics' }, + ], + columns: ['id', 'rank', 'title', 'node', 'replies', 'url'], + pipeline: [ + { fetch: { url: 'https://www.v2ex.com/api/topics/hot.json' } }, + { map: { + id: '${{ item.id }}', + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + node: '${{ item.node.title }}', + replies: '${{ item.replies }}', + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/v2ex/hot.yaml b/clis/v2ex/hot.yaml deleted file mode 100644 index feced29f..00000000 --- a/clis/v2ex/hot.yaml +++ /dev/null @@ -1,28 +0,0 @@ -site: v2ex -name: hot -description: V2EX 热门话题 -domain: www.v2ex.com -strategy: public -browser: false - -args: - limit: - type: int - default: 20 - description: Number of topics - -pipeline: - - fetch: - url: https://www.v2ex.com/api/topics/hot.json - - - map: - id: ${{ item.id }} - rank: ${{ index + 1 }} - title: ${{ item.title }} - node: ${{ item.node.title }} - replies: ${{ item.replies }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [id, rank, title, node, replies, url] diff --git a/clis/v2ex/latest.ts b/clis/v2ex/latest.ts new file mode 100644 index 00000000..21c3e01d --- /dev/null +++ b/clis/v2ex/latest.ts @@ -0,0 +1,26 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'v2ex', + name: 'latest', + description: 'V2EX 最新话题', + domain: 'www.v2ex.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of topics' }, + ], + columns: ['id', 'rank', 'title', 'node', 'replies', 'url'], + pipeline: [ + { fetch: { url: 'https://www.v2ex.com/api/topics/latest.json' } }, + { map: { + id: '${{ item.id }}', + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + node: '${{ item.node.title }}', + replies: '${{ item.replies }}', + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/v2ex/latest.yaml b/clis/v2ex/latest.yaml deleted file mode 100644 index 9ade2215..00000000 --- a/clis/v2ex/latest.yaml +++ /dev/null @@ -1,28 +0,0 @@ -site: v2ex -name: latest -description: V2EX 最新话题 -domain: www.v2ex.com -strategy: public -browser: false - -args: - limit: - type: int - default: 20 - description: Number of topics - -pipeline: - - fetch: - url: https://www.v2ex.com/api/topics/latest.json - - - map: - id: ${{ item.id }} - rank: ${{ index + 1 }} - title: ${{ item.title }} - node: ${{ item.node.title }} - replies: ${{ item.replies }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [id, rank, title, node, replies, url] diff --git a/clis/v2ex/member.ts b/clis/v2ex/member.ts new file mode 100644 index 00000000..1c8abba7 --- /dev/null +++ b/clis/v2ex/member.ts @@ -0,0 +1,28 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'v2ex', + name: 'member', + description: 'V2EX 用户资料', + domain: 'www.v2ex.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'username', required: true, positional: true, help: 'Username' }, + ], + columns: ['username', 'tagline', 'website', 'github', 'twitter', 'location'], + pipeline: [ + { fetch: { + url: 'https://www.v2ex.com/api/members/show.json', + params: { username: '${{ args.username }}' }, + } }, + { map: { + username: '${{ item.username }}', + tagline: '${{ item.tagline }}', + website: '${{ item.website }}', + github: '${{ item.github }}', + twitter: '${{ item.twitter }}', + location: '${{ item.location }}', + } }, + ], +}); diff --git a/clis/v2ex/member.yaml b/clis/v2ex/member.yaml deleted file mode 100644 index b2b21a3f..00000000 --- a/clis/v2ex/member.yaml +++ /dev/null @@ -1,29 +0,0 @@ -site: v2ex -name: member -description: V2EX 用户资料 -domain: www.v2ex.com -strategy: public -browser: false - -args: - username: - positional: true - type: str - required: true - description: Username - -pipeline: - - fetch: - url: https://www.v2ex.com/api/members/show.json - params: - username: ${{ args.username }} - - - map: - username: ${{ item.username }} - tagline: ${{ item.tagline }} - website: ${{ item.website }} - github: ${{ item.github }} - twitter: ${{ item.twitter }} - location: ${{ item.location }} - -columns: [username, tagline, website, github, twitter, location] diff --git a/clis/v2ex/node.ts b/clis/v2ex/node.ts new file mode 100644 index 00000000..2eb7ff18 --- /dev/null +++ b/clis/v2ex/node.ts @@ -0,0 +1,39 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'v2ex', + name: 'node', + description: 'V2EX 节点话题列表', + domain: 'www.v2ex.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { + name: 'name', + required: true, + positional: true, + help: 'Node name (e.g. python, javascript, apple)', + }, + { + name: 'limit', + type: 'int', + default: 10, + help: 'Number of topics (API returns max 20)', + }, + ], + columns: ['rank', 'title', 'author', 'replies', 'url'], + pipeline: [ + { fetch: { + url: 'https://www.v2ex.com/api/topics/show.json', + params: { node_name: '${{ args.name }}' }, + } }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + author: '${{ item.member.username }}', + replies: '${{ item.replies }}', + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/v2ex/node.yaml b/clis/v2ex/node.yaml deleted file mode 100644 index 8c26b6de..00000000 --- a/clis/v2ex/node.yaml +++ /dev/null @@ -1,34 +0,0 @@ -site: v2ex -name: node -description: V2EX 节点话题列表 -domain: www.v2ex.com -strategy: public -browser: false - -args: - name: - positional: true - type: str - required: true - description: Node name (e.g. python, javascript, apple) - limit: - type: int - default: 10 - description: Number of topics (API returns max 20) - -pipeline: - - fetch: - url: https://www.v2ex.com/api/topics/show.json - params: - node_name: ${{ args.name }} - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - author: ${{ item.member.username }} - replies: ${{ item.replies }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [rank, title, author, replies, url] diff --git a/clis/v2ex/nodes.ts b/clis/v2ex/nodes.ts new file mode 100644 index 00000000..47f48a05 --- /dev/null +++ b/clis/v2ex/nodes.ts @@ -0,0 +1,26 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'v2ex', + name: 'nodes', + description: 'V2EX 所有节点列表', + domain: 'www.v2ex.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 30, help: 'Number of nodes' }, + ], + columns: ['rank', 'name', 'title', 'topics', 'stars'], + pipeline: [ + { fetch: { url: 'https://www.v2ex.com/api/nodes/all.json' } }, + { sort: { by: 'topics', order: 'desc' } }, + { map: { + rank: '${{ index + 1 }}', + name: '${{ item.name }}', + title: '${{ item.title }}', + topics: '${{ item.topics }}', + stars: '${{ item.stars }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/v2ex/nodes.yaml b/clis/v2ex/nodes.yaml deleted file mode 100644 index 534a7696..00000000 --- a/clis/v2ex/nodes.yaml +++ /dev/null @@ -1,31 +0,0 @@ -site: v2ex -name: nodes -description: V2EX 所有节点列表 -domain: www.v2ex.com -strategy: public -browser: false - -args: - limit: - type: int - default: 30 - description: Number of nodes - -pipeline: - - fetch: - url: https://www.v2ex.com/api/nodes/all.json - - - sort: - by: topics - order: desc - - - map: - rank: ${{ index + 1 }} - name: ${{ item.name }} - title: ${{ item.title }} - topics: ${{ item.topics }} - stars: ${{ item.stars }} - - - limit: ${{ args.limit }} - -columns: [rank, name, title, topics, stars] diff --git a/clis/v2ex/replies.ts b/clis/v2ex/replies.ts new file mode 100644 index 00000000..48d20e41 --- /dev/null +++ b/clis/v2ex/replies.ts @@ -0,0 +1,27 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'v2ex', + name: 'replies', + description: 'V2EX 主题回复列表', + domain: 'www.v2ex.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'id', required: true, positional: true, help: 'Topic ID' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of replies' }, + ], + columns: ['floor', 'author', 'content'], + pipeline: [ + { fetch: { + url: 'https://www.v2ex.com/api/replies/show.json', + params: { topic_id: '${{ args.id }}' }, + } }, + { map: { + floor: '${{ index + 1 }}', + author: '${{ item.member.username }}', + content: '${{ item.content }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/v2ex/replies.yaml b/clis/v2ex/replies.yaml deleted file mode 100644 index deb24346..00000000 --- a/clis/v2ex/replies.yaml +++ /dev/null @@ -1,32 +0,0 @@ -site: v2ex -name: replies -description: V2EX 主题回复列表 -domain: www.v2ex.com -strategy: public -browser: false - -args: - id: - positional: true - type: str - required: true - description: Topic ID - limit: - type: int - default: 20 - description: Number of replies - -pipeline: - - fetch: - url: https://www.v2ex.com/api/replies/show.json - params: - topic_id: ${{ args.id }} - - - map: - floor: ${{ index + 1 }} - author: ${{ item.member.username }} - content: ${{ item.content }} - - - limit: ${{ args.limit }} - -columns: [floor, author, content] diff --git a/clis/v2ex/topic.ts b/clis/v2ex/topic.ts new file mode 100644 index 00000000..c9d34c91 --- /dev/null +++ b/clis/v2ex/topic.ts @@ -0,0 +1,31 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'v2ex', + name: 'topic', + description: 'V2EX 主题详情和回复', + domain: 'www.v2ex.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'id', required: true, positional: true, help: 'Topic ID' }, + ], + columns: ['id', 'title', 'content', 'member', 'created', 'node', 'replies', 'url'], + pipeline: [ + { fetch: { + url: 'https://www.v2ex.com/api/topics/show.json', + params: { id: '${{ args.id }}' }, + } }, + { map: { + id: '${{ item.id }}', + title: '${{ item.title }}', + content: '${{ item.content }}', + member: '${{ item.member.username }}', + created: '${{ item.created }}', + node: '${{ item.node.title }}', + replies: '${{ item.replies }}', + url: '${{ item.url }}', + } }, + { limit: 1 }, + ], +}); diff --git a/clis/v2ex/topic.yaml b/clis/v2ex/topic.yaml deleted file mode 100644 index 64b2c2f6..00000000 --- a/clis/v2ex/topic.yaml +++ /dev/null @@ -1,33 +0,0 @@ -site: v2ex -name: topic -description: V2EX 主题详情和回复 -domain: www.v2ex.com -strategy: public -browser: false - -args: - id: - positional: true - type: str - required: true - description: Topic ID - -pipeline: - - fetch: - url: https://www.v2ex.com/api/topics/show.json - params: - id: ${{ args.id }} - - - map: - id: ${{ item.id }} - title: ${{ item.title }} - content: ${{ item.content }} - member: ${{ item.member.username }} - created: ${{ item.created }} - node: ${{ item.node.title }} - replies: ${{ item.replies }} - url: ${{ item.url }} - - - limit: 1 - -columns: [id, title, content, member, created, node, replies, url] diff --git a/clis/v2ex/user.ts b/clis/v2ex/user.ts new file mode 100644 index 00000000..0f62c647 --- /dev/null +++ b/clis/v2ex/user.ts @@ -0,0 +1,34 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'v2ex', + name: 'user', + description: 'V2EX 用户发帖列表', + domain: 'www.v2ex.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'username', required: true, positional: true, help: 'Username' }, + { + name: 'limit', + type: 'int', + default: 10, + help: 'Number of topics (API returns max 20)', + }, + ], + columns: ['rank', 'title', 'node', 'replies', 'url'], + pipeline: [ + { fetch: { + url: 'https://www.v2ex.com/api/topics/show.json', + params: { username: '${{ args.username }}' }, + } }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + node: '${{ item.node.title }}', + replies: '${{ item.replies }}', + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/v2ex/user.yaml b/clis/v2ex/user.yaml deleted file mode 100644 index b60dd485..00000000 --- a/clis/v2ex/user.yaml +++ /dev/null @@ -1,34 +0,0 @@ -site: v2ex -name: user -description: V2EX 用户发帖列表 -domain: www.v2ex.com -strategy: public -browser: false - -args: - username: - positional: true - type: str - required: true - description: Username - limit: - type: int - default: 10 - description: Number of topics (API returns max 20) - -pipeline: - - fetch: - url: https://www.v2ex.com/api/topics/show.json - params: - username: ${{ args.username }} - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - node: ${{ item.node.title }} - replies: ${{ item.replies }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [rank, title, node, replies, url] diff --git a/clis/xiaoe/catalog.ts b/clis/xiaoe/catalog.ts new file mode 100644 index 00000000..8c55cc19 --- /dev/null +++ b/clis/xiaoe/catalog.ts @@ -0,0 +1,126 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'xiaoe', + name: 'catalog', + description: '小鹅通课程目录(支持普通课程、专栏、大专栏)', + domain: 'h5.xet.citv.cn', + strategy: Strategy.COOKIE, + args: [ + { name: 'url', required: true, positional: true, help: '课程页面 URL' }, + ], + columns: ['ch', 'chapter', 'no', 'title', 'type', 'resource_id', 'status'], + pipeline: [ + { navigate: '${{ args.url }}' }, + { wait: 8 }, + { evaluate: `(async () => { + var el = document.querySelector('#app'); + var store = (el && el.__vue__) ? el.__vue__.$store : null; + if (!store) return []; + var coreInfo = store.state.coreInfo || {}; + var resourceType = coreInfo.resource_type || 0; + var origin = window.location.origin; + var courseName = coreInfo.resource_name || ''; + + function typeLabel(t) { + return {1:'图文',2:'直播',3:'音频',4:'视频',6:'专栏',8:'大专栏'}[Number(t)] || String(t||''); + } + function buildUrl(item) { + var u = item.jump_url || item.h5_url || item.url || ''; + return (u && !u.startsWith('http')) ? origin + u : u; + } + function clickTab(name) { + var tabs = document.querySelectorAll('span, div'); + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].children.length === 0 && tabs[i].textContent.trim() === name) { + tabs[i].click(); return; + } + } + } + + clickTab('目录'); + await new Promise(function(r) { setTimeout(r, 2000); }); + + // ===== 专栏 / 大专栏 ===== + if (resourceType === 6 || resourceType === 8) { + await new Promise(function(r) { setTimeout(r, 1000); }); + var listData = []; + var walkList = function(vm, depth) { + if (!vm || depth > 6 || listData.length > 0) return; + var d = vm.$data || {}; + var keys = ['columnList', 'SingleItemList', 'chapterChildren']; + for (var ki = 0; ki < keys.length; ki++) { + var arr = d[keys[ki]]; + if (arr && Array.isArray(arr) && arr.length > 0 && arr[0].resource_id) { + for (var j = 0; j < arr.length; j++) { + var item = arr[j]; + if (!item.resource_id || !/^[pvlai]_/.test(item.resource_id)) continue; + listData.push({ + ch: 1, chapter: courseName, no: j + 1, + title: item.resource_title || item.title || item.chapter_title || '', + type: typeLabel(item.resource_type || item.chapter_type), + resource_id: item.resource_id, + url: buildUrl(item), + status: item.finished_state === 1 ? '已完成' : (item.resource_count ? item.resource_count + '节' : ''), + }); + } + return; + } + } + if (vm.$children) { + for (var c = 0; c < vm.$children.length; c++) walkList(vm.$children[c], depth + 1); + } + }; + walkList(el.__vue__, 0); + return listData; + } + + // ===== 普通课程 ===== + var chapters = document.querySelectorAll('.chapter_box'); + for (var ci = 0; ci < chapters.length; ci++) { + var vue = chapters[ci].__vue__; + if (vue && typeof vue.getSecitonList === 'function' && (!vue.isShowSecitonsList || !vue.chapterChildren.length)) { + if (vue.isShowSecitonsList) vue.isShowSecitonsList = false; + try { vue.getSecitonList(); } catch(e) {} + await new Promise(function(r) { setTimeout(r, 1500); }); + } + } + await new Promise(function(r) { setTimeout(r, 3000); }); + + var result = []; + chapters = document.querySelectorAll('.chapter_box'); + for (var cj = 0; cj < chapters.length; cj++) { + var v = chapters[cj].__vue__; + if (!v) continue; + var chTitle = (v.chapterItem && v.chapterItem.chapter_title) || ''; + var children = v.chapterChildren || []; + for (var ck = 0; ck < children.length; ck++) { + var child = children[ck]; + var resId = child.resource_id || child.chapter_id || ''; + var chType = child.chapter_type || child.resource_type || 0; + var urlPath = {1:'/v1/course/text/',2:'/v2/course/alive/',3:'/v1/course/audio/',4:'/v1/course/video/'}[Number(chType)]; + result.push({ + ch: cj + 1, chapter: chTitle, no: ck + 1, + title: child.chapter_title || child.resource_title || '', + type: typeLabel(chType), + resource_id: resId, + url: urlPath ? origin + urlPath + resId + '?type=2' : '', + status: child.is_finish === 1 ? '已完成' : (child.learn_progress > 0 ? child.learn_progress + '%' : '未学'), + }); + } + } + return result; +})() +` }, + { map: { + ch: '${{ item.ch }}', + chapter: '${{ item.chapter }}', + no: '${{ item.no }}', + title: '${{ item.title }}', + type: '${{ item.type }}', + resource_id: '${{ item.resource_id }}', + url: '${{ item.url }}', + status: '${{ item.status }}', + } }, + ], +}); diff --git a/clis/xiaoe/catalog.yaml b/clis/xiaoe/catalog.yaml deleted file mode 100644 index 460b4d99..00000000 --- a/clis/xiaoe/catalog.yaml +++ /dev/null @@ -1,129 +0,0 @@ -site: xiaoe -name: catalog -description: 小鹅通课程目录(支持普通课程、专栏、大专栏) -domain: h5.xet.citv.cn -strategy: cookie - -args: - url: - type: str - required: true - positional: true - description: 课程页面 URL - -pipeline: - - navigate: ${{ args.url }} - - - wait: 8 - - - evaluate: | - (async () => { - var el = document.querySelector('#app'); - var store = (el && el.__vue__) ? el.__vue__.$store : null; - if (!store) return []; - var coreInfo = store.state.coreInfo || {}; - var resourceType = coreInfo.resource_type || 0; - var origin = window.location.origin; - var courseName = coreInfo.resource_name || ''; - - function typeLabel(t) { - return {1:'图文',2:'直播',3:'音频',4:'视频',6:'专栏',8:'大专栏'}[Number(t)] || String(t||''); - } - function buildUrl(item) { - var u = item.jump_url || item.h5_url || item.url || ''; - return (u && !u.startsWith('http')) ? origin + u : u; - } - function clickTab(name) { - var tabs = document.querySelectorAll('span, div'); - for (var i = 0; i < tabs.length; i++) { - if (tabs[i].children.length === 0 && tabs[i].textContent.trim() === name) { - tabs[i].click(); return; - } - } - } - - clickTab('目录'); - await new Promise(function(r) { setTimeout(r, 2000); }); - - // ===== 专栏 / 大专栏 ===== - if (resourceType === 6 || resourceType === 8) { - await new Promise(function(r) { setTimeout(r, 1000); }); - var listData = []; - var walkList = function(vm, depth) { - if (!vm || depth > 6 || listData.length > 0) return; - var d = vm.$data || {}; - var keys = ['columnList', 'SingleItemList', 'chapterChildren']; - for (var ki = 0; ki < keys.length; ki++) { - var arr = d[keys[ki]]; - if (arr && Array.isArray(arr) && arr.length > 0 && arr[0].resource_id) { - for (var j = 0; j < arr.length; j++) { - var item = arr[j]; - if (!item.resource_id || !/^[pvlai]_/.test(item.resource_id)) continue; - listData.push({ - ch: 1, chapter: courseName, no: j + 1, - title: item.resource_title || item.title || item.chapter_title || '', - type: typeLabel(item.resource_type || item.chapter_type), - resource_id: item.resource_id, - url: buildUrl(item), - status: item.finished_state === 1 ? '已完成' : (item.resource_count ? item.resource_count + '节' : ''), - }); - } - return; - } - } - if (vm.$children) { - for (var c = 0; c < vm.$children.length; c++) walkList(vm.$children[c], depth + 1); - } - }; - walkList(el.__vue__, 0); - return listData; - } - - // ===== 普通课程 ===== - var chapters = document.querySelectorAll('.chapter_box'); - for (var ci = 0; ci < chapters.length; ci++) { - var vue = chapters[ci].__vue__; - if (vue && typeof vue.getSecitonList === 'function' && (!vue.isShowSecitonsList || !vue.chapterChildren.length)) { - if (vue.isShowSecitonsList) vue.isShowSecitonsList = false; - try { vue.getSecitonList(); } catch(e) {} - await new Promise(function(r) { setTimeout(r, 1500); }); - } - } - await new Promise(function(r) { setTimeout(r, 3000); }); - - var result = []; - chapters = document.querySelectorAll('.chapter_box'); - for (var cj = 0; cj < chapters.length; cj++) { - var v = chapters[cj].__vue__; - if (!v) continue; - var chTitle = (v.chapterItem && v.chapterItem.chapter_title) || ''; - var children = v.chapterChildren || []; - for (var ck = 0; ck < children.length; ck++) { - var child = children[ck]; - var resId = child.resource_id || child.chapter_id || ''; - var chType = child.chapter_type || child.resource_type || 0; - var urlPath = {1:'/v1/course/text/',2:'/v2/course/alive/',3:'/v1/course/audio/',4:'/v1/course/video/'}[Number(chType)]; - result.push({ - ch: cj + 1, chapter: chTitle, no: ck + 1, - title: child.chapter_title || child.resource_title || '', - type: typeLabel(chType), - resource_id: resId, - url: urlPath ? origin + urlPath + resId + '?type=2' : '', - status: child.is_finish === 1 ? '已完成' : (child.learn_progress > 0 ? child.learn_progress + '%' : '未学'), - }); - } - } - return result; - })() - - - map: - ch: ${{ item.ch }} - chapter: ${{ item.chapter }} - no: ${{ item.no }} - title: ${{ item.title }} - type: ${{ item.type }} - resource_id: ${{ item.resource_id }} - url: ${{ item.url }} - status: ${{ item.status }} - -columns: [ch, chapter, no, title, type, resource_id, status] diff --git a/clis/xiaoe/content.ts b/clis/xiaoe/content.ts new file mode 100644 index 00000000..96683af4 --- /dev/null +++ b/clis/xiaoe/content.ts @@ -0,0 +1,40 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'xiaoe', + name: 'content', + description: '提取小鹅通图文页面内容为文本', + domain: 'h5.xet.citv.cn', + strategy: Strategy.COOKIE, + args: [ + { name: 'url', required: true, positional: true, help: '页面 URL' }, + ], + columns: ['title', 'content_length', 'image_count'], + pipeline: [ + { navigate: '${{ args.url }}' }, + { wait: 6 }, + { evaluate: `(() => { + var selectors = ['.rich-text-wrap','.content-wrap','.article-content','.text-content', + '.course-detail','.detail-content','[class*="richtext"]','[class*="rich-text"]','.ql-editor']; + var content = ''; + for (var i = 0; i < selectors.length; i++) { + var el = document.querySelector(selectors[i]); + if (el && el.innerText.trim().length > 50) { content = el.innerText.trim(); break; } + } + if (!content) content = (document.querySelector('main') || document.querySelector('#app') || document.body).innerText.trim(); + + var images = []; + document.querySelectorAll('img').forEach(function(img) { + if (img.src && !img.src.startsWith('data:') && img.src.includes('xiaoe')) images.push(img.src); + }); + return [{ + title: document.title, + content: content, + content_length: content.length, + image_count: images.length, + images: JSON.stringify(images.slice(0, 20)), + }]; +})() +` }, + ], +}); diff --git a/clis/xiaoe/content.yaml b/clis/xiaoe/content.yaml deleted file mode 100644 index 9b8c42e5..00000000 --- a/clis/xiaoe/content.yaml +++ /dev/null @@ -1,43 +0,0 @@ -site: xiaoe -name: content -description: 提取小鹅通图文页面内容为文本 -domain: h5.xet.citv.cn -strategy: cookie - -args: - url: - type: str - required: true - positional: true - description: 页面 URL - -pipeline: - - navigate: ${{ args.url }} - - - wait: 6 - - - evaluate: | - (() => { - var selectors = ['.rich-text-wrap','.content-wrap','.article-content','.text-content', - '.course-detail','.detail-content','[class*="richtext"]','[class*="rich-text"]','.ql-editor']; - var content = ''; - for (var i = 0; i < selectors.length; i++) { - var el = document.querySelector(selectors[i]); - if (el && el.innerText.trim().length > 50) { content = el.innerText.trim(); break; } - } - if (!content) content = (document.querySelector('main') || document.querySelector('#app') || document.body).innerText.trim(); - - var images = []; - document.querySelectorAll('img').forEach(function(img) { - if (img.src && !img.src.startsWith('data:') && img.src.includes('xiaoe')) images.push(img.src); - }); - return [{ - title: document.title, - content: content, - content_length: content.length, - image_count: images.length, - images: JSON.stringify(images.slice(0, 20)), - }]; - })() - -columns: [title, content_length, image_count] diff --git a/clis/xiaoe/courses.ts b/clis/xiaoe/courses.ts new file mode 100644 index 00000000..e5fb3394 --- /dev/null +++ b/clis/xiaoe/courses.ts @@ -0,0 +1,70 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'xiaoe', + name: 'courses', + description: '列出已购小鹅通课程(含 URL 和店铺名)', + domain: 'study.xiaoe-tech.com', + strategy: Strategy.COOKIE, + columns: ['title', 'shop', 'url'], + pipeline: [ + { navigate: 'https://study.xiaoe-tech.com/' }, + { wait: 8 }, + { evaluate: `(async () => { + // 切换到「内容」tab + var tabs = document.querySelectorAll('span, div'); + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].children.length === 0 && tabs[i].textContent.trim() === '内容') { + tabs[i].click(); + break; + } + } + await new Promise(function(r) { setTimeout(r, 2000); }); + + // 匹配课程卡片标题与 Vue 数据 + function matchEntry(title, vm, depth) { + if (!vm || depth > 5) return null; + var d = vm.$data || {}; + for (var k in d) { + if (!Array.isArray(d[k])) continue; + for (var j = 0; j < d[k].length; j++) { + var e = d[k][j]; + if (!e || typeof e !== 'object') continue; + var t = e.title || e.resource_name || ''; + if (t && title.includes(t.substring(0, 10))) return e; + } + } + return vm.$parent ? matchEntry(title, vm.$parent, depth + 1) : null; + } + + // 构造课程 URL + function buildUrl(entry) { + if (entry.h5_url) return entry.h5_url; + if (entry.url) return entry.url; + if (entry.app_id && entry.resource_id) { + var base = 'https://' + entry.app_id + '.h5.xet.citv.cn'; + if (entry.resource_type === 6) return base + '/v1/course/column/' + entry.resource_id + '?type=3'; + return base + '/p/course/ecourse/' + entry.resource_id; + } + return ''; + } + + var cards = document.querySelectorAll('.course-card-list'); + var results = []; + for (var c = 0; c < cards.length; c++) { + var titleEl = cards[c].querySelector('.card-title-box'); + var title = titleEl ? titleEl.textContent.trim() : ''; + if (!title) continue; + var entry = matchEntry(title, cards[c].__vue__, 0); + results.push({ + title: title, + shop: entry ? (entry.shop_name || entry.app_name || '') : '', + url: entry ? buildUrl(entry) : '', + }); + } + return results; +})() +` }, + { map: { title: '${{ item.title }}', shop: '${{ item.shop }}', url: '${{ item.url }}' } }, + ], +}); diff --git a/clis/xiaoe/courses.yaml b/clis/xiaoe/courses.yaml deleted file mode 100644 index a5ee7375..00000000 --- a/clis/xiaoe/courses.yaml +++ /dev/null @@ -1,73 +0,0 @@ -site: xiaoe -name: courses -description: 列出已购小鹅通课程(含 URL 和店铺名) -domain: study.xiaoe-tech.com -strategy: cookie - -pipeline: - - navigate: https://study.xiaoe-tech.com/ - - - wait: 8 - - - evaluate: | - (async () => { - // 切换到「内容」tab - var tabs = document.querySelectorAll('span, div'); - for (var i = 0; i < tabs.length; i++) { - if (tabs[i].children.length === 0 && tabs[i].textContent.trim() === '内容') { - tabs[i].click(); - break; - } - } - await new Promise(function(r) { setTimeout(r, 2000); }); - - // 匹配课程卡片标题与 Vue 数据 - function matchEntry(title, vm, depth) { - if (!vm || depth > 5) return null; - var d = vm.$data || {}; - for (var k in d) { - if (!Array.isArray(d[k])) continue; - for (var j = 0; j < d[k].length; j++) { - var e = d[k][j]; - if (!e || typeof e !== 'object') continue; - var t = e.title || e.resource_name || ''; - if (t && title.includes(t.substring(0, 10))) return e; - } - } - return vm.$parent ? matchEntry(title, vm.$parent, depth + 1) : null; - } - - // 构造课程 URL - function buildUrl(entry) { - if (entry.h5_url) return entry.h5_url; - if (entry.url) return entry.url; - if (entry.app_id && entry.resource_id) { - var base = 'https://' + entry.app_id + '.h5.xet.citv.cn'; - if (entry.resource_type === 6) return base + '/v1/course/column/' + entry.resource_id + '?type=3'; - return base + '/p/course/ecourse/' + entry.resource_id; - } - return ''; - } - - var cards = document.querySelectorAll('.course-card-list'); - var results = []; - for (var c = 0; c < cards.length; c++) { - var titleEl = cards[c].querySelector('.card-title-box'); - var title = titleEl ? titleEl.textContent.trim() : ''; - if (!title) continue; - var entry = matchEntry(title, cards[c].__vue__, 0); - results.push({ - title: title, - shop: entry ? (entry.shop_name || entry.app_name || '') : '', - url: entry ? buildUrl(entry) : '', - }); - } - return results; - })() - - - map: - title: ${{ item.title }} - shop: ${{ item.shop }} - url: ${{ item.url }} - -columns: [title, shop, url] diff --git a/clis/xiaoe/detail.ts b/clis/xiaoe/detail.ts new file mode 100644 index 00000000..44a8e7e2 --- /dev/null +++ b/clis/xiaoe/detail.ts @@ -0,0 +1,36 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'xiaoe', + name: 'detail', + description: '小鹅通课程详情(名称、价格、学员数、店铺)', + domain: 'h5.xet.citv.cn', + strategy: Strategy.COOKIE, + args: [ + { name: 'url', required: true, positional: true, help: '课程页面 URL' }, + ], + columns: ['name', 'price', 'original_price', 'user_count', 'shop_name'], + pipeline: [ + { navigate: '${{ args.url }}' }, + { wait: 5 }, + { evaluate: `(() => { + var vm = (document.querySelector('#app') || {}).__vue__; + if (!vm || !vm.$store) return []; + var core = vm.$store.state.coreInfo || {}; + var goods = vm.$store.state.goodsInfo || {}; + var shop = ((vm.$store.state.compositeInfo || {}).shop_conf) || {}; + return [{ + name: core.resource_name || '', + resource_id: core.resource_id || '', + resource_type: core.resource_type || '', + cover: core.resource_img || '', + user_count: core.user_count || 0, + price: goods.price ? (goods.price / 100).toFixed(2) : '0', + original_price: goods.line_price ? (goods.line_price / 100).toFixed(2) : '0', + is_free: goods.is_free || 0, + shop_name: shop.shop_name || '', + }]; +})() +` }, + ], +}); diff --git a/clis/xiaoe/detail.yaml b/clis/xiaoe/detail.yaml deleted file mode 100644 index 645fa487..00000000 --- a/clis/xiaoe/detail.yaml +++ /dev/null @@ -1,39 +0,0 @@ -site: xiaoe -name: detail -description: 小鹅通课程详情(名称、价格、学员数、店铺) -domain: h5.xet.citv.cn -strategy: cookie - -args: - url: - type: str - required: true - positional: true - description: 课程页面 URL - -pipeline: - - navigate: ${{ args.url }} - - - wait: 5 - - - evaluate: | - (() => { - var vm = (document.querySelector('#app') || {}).__vue__; - if (!vm || !vm.$store) return []; - var core = vm.$store.state.coreInfo || {}; - var goods = vm.$store.state.goodsInfo || {}; - var shop = ((vm.$store.state.compositeInfo || {}).shop_conf) || {}; - return [{ - name: core.resource_name || '', - resource_id: core.resource_id || '', - resource_type: core.resource_type || '', - cover: core.resource_img || '', - user_count: core.user_count || 0, - price: goods.price ? (goods.price / 100).toFixed(2) : '0', - original_price: goods.line_price ? (goods.line_price / 100).toFixed(2) : '0', - is_free: goods.is_free || 0, - shop_name: shop.shop_name || '', - }]; - })() - -columns: [name, price, original_price, user_count, shop_name] diff --git a/clis/xiaoe/play-url.ts b/clis/xiaoe/play-url.ts new file mode 100644 index 00000000..7ebb5a02 --- /dev/null +++ b/clis/xiaoe/play-url.ts @@ -0,0 +1,121 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'xiaoe', + name: 'play-url', + description: '小鹅通视频/音频/直播回放 M3U8 播放地址', + domain: 'h5.xet.citv.cn', + strategy: Strategy.COOKIE, + args: [ + { name: 'url', required: true, positional: true, help: '小节页面 URL' }, + ], + columns: ['title', 'resource_id', 'm3u8_url', 'duration_sec', 'method'], + pipeline: [ + { navigate: '${{ args.url }}' }, + { wait: 2 }, + { evaluate: `(async () => { + var pageUrl = window.location.href; + var origin = window.location.origin; + var resourceId = (pageUrl.match(/[val]_[a-f0-9]+/) || [])[0] || ''; + var productId = (pageUrl.match(/product_id=([^&]+)/) || [])[1] || ''; + var appId = (origin.match(/(app[a-z0-9]+)\\./) || [])[1] || ''; + var isLive = resourceId.startsWith('l_') || pageUrl.includes('/alive/'); + var m3u8Url = '', method = '', title = document.title, duration = 0; + + // 深度搜索 Vue 组件树找 M3U8 + function searchVueM3u8() { + var el = document.querySelector('#app'); + if (!el || !el.__vue__) return ''; + var walk = function(vm, d) { + if (!vm || d > 10) return ''; + var data = vm.$data || {}; + for (var k in data) { + if (k[0] === '_' || k[0] === '$') continue; + var v = data[k]; + if (typeof v === 'string' && v.includes('.m3u8')) return v; + if (typeof v === 'object' && v) { + try { + var s = JSON.stringify(v); + var m = s.match(/https?:[^"]*\\.m3u8[^"]*/); + if (m) return m[0].replace(/\\\\\\//g, '/'); + } catch(e) {} + } + } + if (vm.$children) { + for (var c = 0; c < vm.$children.length; c++) { + var f = walk(vm.$children[c], d + 1); + if (f) return f; + } + } + return ''; + }; + return walk(el.__vue__, 0); + } + + // ===== 视频课: detail_info → getPlayUrl ===== + if (!isLive && resourceId.startsWith('v_')) { + try { + var detailRes = await fetch(origin + '/xe.course.business.video.detail_info.get/2.0.0', { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + 'bizData[resource_id]': resourceId, + 'bizData[product_id]': productId || resourceId, + 'bizData[opr_sys]': 'MacIntel', + }), + }); + var detail = await detailRes.json(); + var vi = (detail.data || {}).video_info || {}; + title = vi.file_name || title; + duration = vi.video_length || 0; + if (vi.play_sign) { + var userId = (document.cookie.match(/ctx_user_id=([^;]+)/) || [])[1] || window.__user_id || ''; + var playRes = await fetch(origin + '/xe.material-center.play/getPlayUrl', { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + org_app_id: appId, app_id: vi.material_app_id || appId, + user_id: userId, play_sign: [vi.play_sign], + play_line: 'A', opr_sys: 'MacIntel', + }), + }); + var playData = await playRes.json(); + if (playData.code === 0 && playData.data) { + var m = JSON.stringify(playData.data).match(/https?:[^"]*\\.m3u8[^"]*/); + if (m) { m3u8Url = m[0].replace(/\\\\u0026/g, '&').replace(/\\\\\\//g, '/'); method = 'api_direct'; } + } + } + } catch(e) {} + } + + // ===== 兜底: Performance API + Vue 搜索轮询 ===== + if (!m3u8Url) { + for (var attempt = 0; attempt < 30; attempt++) { + var entries = performance.getEntriesByType('resource'); + for (var i = 0; i < entries.length; i++) { + if (entries[i].name.includes('.m3u8')) { m3u8Url = entries[i].name; method = 'perf_api'; break; } + } + if (!m3u8Url) { m3u8Url = searchVueM3u8(); if (m3u8Url) method = 'vue_search'; } + if (m3u8Url) break; + await new Promise(function(r) { setTimeout(r, 500); }); + } + } + + if (!duration) { + var vid = document.querySelector('video'), aud = document.querySelector('audio'); + if (vid && vid.duration && !isNaN(vid.duration)) duration = Math.round(vid.duration); + if (aud && aud.duration && !isNaN(aud.duration)) duration = Math.round(aud.duration); + } + + return [{ title: title, resource_id: resourceId, m3u8_url: m3u8Url, duration_sec: duration, method: method }]; +})() +` }, + { map: { + title: '${{ item.title }}', + resource_id: '${{ item.resource_id }}', + m3u8_url: '${{ item.m3u8_url }}', + duration_sec: '${{ item.duration_sec }}', + method: '${{ item.method }}', + } }, + ], +}); diff --git a/clis/xiaoe/play-url.yaml b/clis/xiaoe/play-url.yaml deleted file mode 100644 index b30a3ac7..00000000 --- a/clis/xiaoe/play-url.yaml +++ /dev/null @@ -1,124 +0,0 @@ -site: xiaoe -name: play-url -description: 小鹅通视频/音频/直播回放 M3U8 播放地址 -domain: h5.xet.citv.cn -strategy: cookie - -args: - url: - type: str - required: true - positional: true - description: 小节页面 URL - -pipeline: - - navigate: ${{ args.url }} - - - wait: 2 - - - evaluate: | - (async () => { - var pageUrl = window.location.href; - var origin = window.location.origin; - var resourceId = (pageUrl.match(/[val]_[a-f0-9]+/) || [])[0] || ''; - var productId = (pageUrl.match(/product_id=([^&]+)/) || [])[1] || ''; - var appId = (origin.match(/(app[a-z0-9]+)\./) || [])[1] || ''; - var isLive = resourceId.startsWith('l_') || pageUrl.includes('/alive/'); - var m3u8Url = '', method = '', title = document.title, duration = 0; - - // 深度搜索 Vue 组件树找 M3U8 - function searchVueM3u8() { - var el = document.querySelector('#app'); - if (!el || !el.__vue__) return ''; - var walk = function(vm, d) { - if (!vm || d > 10) return ''; - var data = vm.$data || {}; - for (var k in data) { - if (k[0] === '_' || k[0] === '$') continue; - var v = data[k]; - if (typeof v === 'string' && v.includes('.m3u8')) return v; - if (typeof v === 'object' && v) { - try { - var s = JSON.stringify(v); - var m = s.match(/https?:[^"]*\.m3u8[^"]*/); - if (m) return m[0].replace(/\\\//g, '/'); - } catch(e) {} - } - } - if (vm.$children) { - for (var c = 0; c < vm.$children.length; c++) { - var f = walk(vm.$children[c], d + 1); - if (f) return f; - } - } - return ''; - }; - return walk(el.__vue__, 0); - } - - // ===== 视频课: detail_info → getPlayUrl ===== - if (!isLive && resourceId.startsWith('v_')) { - try { - var detailRes = await fetch(origin + '/xe.course.business.video.detail_info.get/2.0.0', { - method: 'POST', credentials: 'include', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - 'bizData[resource_id]': resourceId, - 'bizData[product_id]': productId || resourceId, - 'bizData[opr_sys]': 'MacIntel', - }), - }); - var detail = await detailRes.json(); - var vi = (detail.data || {}).video_info || {}; - title = vi.file_name || title; - duration = vi.video_length || 0; - if (vi.play_sign) { - var userId = (document.cookie.match(/ctx_user_id=([^;]+)/) || [])[1] || window.__user_id || ''; - var playRes = await fetch(origin + '/xe.material-center.play/getPlayUrl', { - method: 'POST', credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - org_app_id: appId, app_id: vi.material_app_id || appId, - user_id: userId, play_sign: [vi.play_sign], - play_line: 'A', opr_sys: 'MacIntel', - }), - }); - var playData = await playRes.json(); - if (playData.code === 0 && playData.data) { - var m = JSON.stringify(playData.data).match(/https?:[^"]*\.m3u8[^"]*/); - if (m) { m3u8Url = m[0].replace(/\\u0026/g, '&').replace(/\\\//g, '/'); method = 'api_direct'; } - } - } - } catch(e) {} - } - - // ===== 兜底: Performance API + Vue 搜索轮询 ===== - if (!m3u8Url) { - for (var attempt = 0; attempt < 30; attempt++) { - var entries = performance.getEntriesByType('resource'); - for (var i = 0; i < entries.length; i++) { - if (entries[i].name.includes('.m3u8')) { m3u8Url = entries[i].name; method = 'perf_api'; break; } - } - if (!m3u8Url) { m3u8Url = searchVueM3u8(); if (m3u8Url) method = 'vue_search'; } - if (m3u8Url) break; - await new Promise(function(r) { setTimeout(r, 500); }); - } - } - - if (!duration) { - var vid = document.querySelector('video'), aud = document.querySelector('audio'); - if (vid && vid.duration && !isNaN(vid.duration)) duration = Math.round(vid.duration); - if (aud && aud.duration && !isNaN(aud.duration)) duration = Math.round(aud.duration); - } - - return [{ title: title, resource_id: resourceId, m3u8_url: m3u8Url, duration_sec: duration, method: method }]; - })() - - - map: - title: ${{ item.title }} - resource_id: ${{ item.resource_id }} - m3u8_url: ${{ item.m3u8_url }} - duration_sec: ${{ item.duration_sec }} - method: ${{ item.method }} - -columns: [title, resource_id, m3u8_url, duration_sec, method] diff --git a/clis/xiaohongshu/feed.ts b/clis/xiaohongshu/feed.ts new file mode 100644 index 00000000..cc2d4fca --- /dev/null +++ b/clis/xiaohongshu/feed.ts @@ -0,0 +1,33 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'xiaohongshu', + name: 'feed', + description: '小红书首页推荐 Feed (via Pinia Store Action)', + domain: 'www.xiaohongshu.com', + strategy: Strategy.INTERCEPT, + browser: true, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of items to return' }, + ], + columns: ['title', 'author', 'likes', 'type', 'url'], + pipeline: [ + { navigate: 'https://www.xiaohongshu.com/explore' }, + { tap: { + store: 'feed', + action: 'fetchFeeds', + capture: 'homefeed', + select: 'data.items', + timeout: 8, + } }, + { map: { + id: '${{ item.id }}', + title: '${{ item.note_card.display_title }}', + type: '${{ item.note_card.type }}', + author: '${{ item.note_card.user.nickname }}', + likes: '${{ item.note_card.interact_info.liked_count }}', + url: 'https://www.xiaohongshu.com/explore/${{ item.id }}', + } }, + { limit: '${{ args.limit | default(20) }}' }, + ], +}); diff --git a/clis/xiaohongshu/feed.yaml b/clis/xiaohongshu/feed.yaml deleted file mode 100644 index f33ca261..00000000 --- a/clis/xiaohongshu/feed.yaml +++ /dev/null @@ -1,31 +0,0 @@ -site: xiaohongshu -name: feed -description: "小红书首页推荐 Feed (via Pinia Store Action)" -domain: www.xiaohongshu.com -strategy: intercept -browser: true - -args: - limit: - type: int - default: 20 - description: Number of items to return - -columns: [title, author, likes, type, url] - -pipeline: - - navigate: https://www.xiaohongshu.com/explore - - tap: - store: feed - action: fetchFeeds - capture: homefeed - select: data.items - timeout: 8 - - map: - id: ${{ item.id }} - title: ${{ item.note_card.display_title }} - type: ${{ item.note_card.type }} - author: ${{ item.note_card.user.nickname }} - likes: ${{ item.note_card.interact_info.liked_count }} - url: https://www.xiaohongshu.com/explore/${{ item.id }} - - limit: ${{ args.limit | default(20) }} diff --git a/clis/xiaohongshu/notifications.ts b/clis/xiaohongshu/notifications.ts new file mode 100644 index 00000000..cdd3e731 --- /dev/null +++ b/clis/xiaohongshu/notifications.ts @@ -0,0 +1,39 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'xiaohongshu', + name: 'notifications', + description: '小红书通知 (mentions/likes/connections)', + domain: 'www.xiaohongshu.com', + strategy: Strategy.INTERCEPT, + browser: true, + args: [ + { + name: 'type', + default: 'mentions', + help: 'Notification type: mentions, likes, or connections', + }, + { name: 'limit', type: 'int', default: 20, help: 'Number of notifications to return' }, + ], + columns: ['rank', 'user', 'action', 'content', 'note', 'time'], + pipeline: [ + { navigate: 'https://www.xiaohongshu.com/notification' }, + { tap: { + store: 'notification', + action: 'getNotification', + args: [`\${{ args.type | default('mentions') }}`], + capture: '/you/', + select: 'data.message_list', + timeout: 8, + } }, + { map: { + rank: '${{ index + 1 }}', + user: '${{ item.user_info.nickname }}', + action: '${{ item.title }}', + content: '${{ item.comment_info.content }}', + note: '${{ item.item_info.content }}', + time: '${{ item.time }}', + } }, + { limit: '${{ args.limit | default(20) }}' }, + ], +}); diff --git a/clis/xiaohongshu/notifications.yaml b/clis/xiaohongshu/notifications.yaml deleted file mode 100644 index 723543f1..00000000 --- a/clis/xiaohongshu/notifications.yaml +++ /dev/null @@ -1,37 +0,0 @@ -site: xiaohongshu -name: notifications -description: "小红书通知 (mentions/likes/connections)" -domain: www.xiaohongshu.com -strategy: intercept -browser: true - -args: - type: - type: str - default: mentions - description: "Notification type: mentions, likes, or connections" - limit: - type: int - default: 20 - description: Number of notifications to return - -columns: [rank, user, action, content, note, time] - -pipeline: - - navigate: https://www.xiaohongshu.com/notification - - tap: - store: notification - action: getNotification - args: - - ${{ args.type | default('mentions') }} - capture: /you/ - select: data.message_list - timeout: 8 - - map: - rank: ${{ index + 1 }} - user: ${{ item.user_info.nickname }} - action: ${{ item.title }} - content: ${{ item.comment_info.content }} - note: ${{ item.item_info.content }} - time: ${{ item.time }} - - limit: ${{ args.limit | default(20) }} diff --git a/clis/xueqiu/earnings-date.ts b/clis/xueqiu/earnings-date.ts new file mode 100644 index 00000000..f708b163 --- /dev/null +++ b/clis/xueqiu/earnings-date.ts @@ -0,0 +1,62 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'xueqiu', + name: 'earnings-date', + description: '获取股票预计财报发布日期(公司大事)', + domain: 'xueqiu.com', + browser: true, + args: [ + { + name: 'symbol', + required: true, + positional: true, + help: '股票代码,如 SH600519、SZ000858、00700', + }, + { name: 'next', type: 'bool', default: false, help: '仅返回最近一次未发布的财报日期' }, + { name: 'limit', type: 'int', default: 10, help: '返回数量,默认 10' }, + ], + columns: ['date', 'report', 'status'], + pipeline: [ + { navigate: 'https://xueqiu.com' }, + { evaluate: `(async () => { + const symbol = (\${{ args.symbol | json }} || '').toUpperCase(); + const onlyNext = \${{ args.next }}; + if (!symbol) throw new Error('Missing argument: symbol'); + const resp = await fetch( + \`https://stock.xueqiu.com/v5/stock/screener/event/list.json?symbol=\${encodeURIComponent(symbol)}&page=1&size=100\`, + { credentials: 'include' } + ); + if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?'); + const d = await resp.json(); + if (!d.data || !d.data.items) throw new Error('获取失败: ' + JSON.stringify(d)); + + // subtype 2 = 预计财报发布 + let items = d.data.items.filter(item => item.subtype === 2); + + const now = Date.now(); + let results = items.map(item => { + const ts = item.timestamp; + const dateStr = ts ? new Date(ts).toISOString().split('T')[0] : null; + const isFuture = ts && ts > now; + return { + date: dateStr, + report: item.message, + status: isFuture ? '⏳ 未发布' : '✅ 已发布', + _ts: ts, + _future: isFuture + }; + }); + + if (onlyNext) { + const future = results.filter(r => r._future).sort((a, b) => a._ts - b._ts); + results = future.length ? [future[0]] : []; + } + + return results; +})() +` }, + { map: { date: '${{ item.date }}', report: '${{ item.report }}', status: '${{ item.status }}' } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/xueqiu/earnings-date.yaml b/clis/xueqiu/earnings-date.yaml deleted file mode 100644 index e2147fe3..00000000 --- a/clis/xueqiu/earnings-date.yaml +++ /dev/null @@ -1,69 +0,0 @@ -site: xueqiu -name: earnings-date -description: 获取股票预计财报发布日期(公司大事) -domain: xueqiu.com -browser: true - -args: - symbol: - positional: true - type: str - required: true - description: 股票代码,如 SH600519、SZ000858、00700 - next: - type: bool - default: false - description: 仅返回最近一次未发布的财报日期 - limit: - type: int - default: 10 - description: 返回数量,默认 10 - -pipeline: - - navigate: https://xueqiu.com - - evaluate: | - (async () => { - const symbol = (${{ args.symbol | json }} || '').toUpperCase(); - const onlyNext = ${{ args.next }}; - if (!symbol) throw new Error('Missing argument: symbol'); - const resp = await fetch( - `https://stock.xueqiu.com/v5/stock/screener/event/list.json?symbol=${encodeURIComponent(symbol)}&page=1&size=100`, - { credentials: 'include' } - ); - if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?'); - const d = await resp.json(); - if (!d.data || !d.data.items) throw new Error('获取失败: ' + JSON.stringify(d)); - - // subtype 2 = 预计财报发布 - let items = d.data.items.filter(item => item.subtype === 2); - - const now = Date.now(); - let results = items.map(item => { - const ts = item.timestamp; - const dateStr = ts ? new Date(ts).toISOString().split('T')[0] : null; - const isFuture = ts && ts > now; - return { - date: dateStr, - report: item.message, - status: isFuture ? '⏳ 未发布' : '✅ 已发布', - _ts: ts, - _future: isFuture - }; - }); - - if (onlyNext) { - const future = results.filter(r => r._future).sort((a, b) => a._ts - b._ts); - results = future.length ? [future[0]] : []; - } - - return results; - })() - - - map: - date: ${{ item.date }} - report: ${{ item.report }} - status: ${{ item.status }} - - - limit: ${{ args.limit }} - -columns: [date, report, status] diff --git a/clis/xueqiu/feed.ts b/clis/xueqiu/feed.ts new file mode 100644 index 00000000..c03562b2 --- /dev/null +++ b/clis/xueqiu/feed.ts @@ -0,0 +1,49 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'xueqiu', + name: 'feed', + description: '获取雪球首页时间线(关注用户的动态)', + domain: 'xueqiu.com', + browser: true, + args: [ + { name: 'page', type: 'int', default: 1, help: '页码,默认 1' }, + { name: 'limit', type: 'int', default: 20, help: '每页数量,默认 20' }, + ], + columns: ['author', 'text', 'likes', 'replies', 'url'], + pipeline: [ + { navigate: 'https://xueqiu.com' }, + { evaluate: `(async () => { + const page = \${{ args.page }}; + const count = \${{ args.limit }}; + const resp = await fetch(\`https://xueqiu.com/v4/statuses/home_timeline.json?page=\${page}&count=\${count}\`, {credentials: 'include'}); + if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?'); + const d = await resp.json(); + + const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim(); + const list = d.home_timeline || d.list || []; + return list.map(item => { + const user = item.user || {}; + return { + id: item.id, + text: strip(item.description).substring(0, 200), + url: 'https://xueqiu.com/' + user.id + '/' + item.id, + author: user.screen_name, + likes: item.fav_count, + retweets: item.retweet_count, + replies: item.reply_count, + created_at: item.created_at ? new Date(item.created_at).toISOString() : null + }; + }); +})() +` }, + { map: { + author: '${{ item.author }}', + text: '${{ item.text }}', + likes: '${{ item.likes }}', + replies: '${{ item.replies }}', + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/xueqiu/feed.yaml b/clis/xueqiu/feed.yaml deleted file mode 100644 index 3e8172a9..00000000 --- a/clis/xueqiu/feed.yaml +++ /dev/null @@ -1,53 +0,0 @@ -site: xueqiu -name: feed -description: 获取雪球首页时间线(关注用户的动态) -domain: xueqiu.com -browser: true - -args: - page: - type: int - default: 1 - description: 页码,默认 1 - limit: - type: int - default: 20 - description: 每页数量,默认 20 - -pipeline: - - navigate: https://xueqiu.com - - evaluate: | - (async () => { - const page = ${{ args.page }}; - const count = ${{ args.limit }}; - const resp = await fetch(`https://xueqiu.com/v4/statuses/home_timeline.json?page=${page}&count=${count}`, {credentials: 'include'}); - if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?'); - const d = await resp.json(); - - const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim(); - const list = d.home_timeline || d.list || []; - return list.map(item => { - const user = item.user || {}; - return { - id: item.id, - text: strip(item.description).substring(0, 200), - url: 'https://xueqiu.com/' + user.id + '/' + item.id, - author: user.screen_name, - likes: item.fav_count, - retweets: item.retweet_count, - replies: item.reply_count, - created_at: item.created_at ? new Date(item.created_at).toISOString() : null - }; - }); - })() - - - map: - author: ${{ item.author }} - text: ${{ item.text }} - likes: ${{ item.likes }} - replies: ${{ item.replies }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [author, text, likes, replies, url] diff --git a/clis/xueqiu/groups.ts b/clis/xueqiu/groups.ts new file mode 100644 index 00000000..e90de76d --- /dev/null +++ b/clis/xueqiu/groups.ts @@ -0,0 +1,26 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'xueqiu', + name: 'groups', + description: '获取雪球自选股分组列表(含模拟组合)', + domain: 'xueqiu.com', + browser: true, + columns: ['pid', 'name', 'count'], + pipeline: [ + { navigate: 'https://xueqiu.com' }, + { evaluate: `(async () => { + const resp = await fetch('https://stock.xueqiu.com/v5/stock/portfolio/list.json?category=1&size=20', {credentials: 'include'}); + if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?'); + const d = await resp.json(); + if (!d.data || !d.data.stocks) throw new Error('获取失败,可能未登录'); + + return d.data.stocks.map(g => ({ + pid: String(g.id), + name: g.name, + count: g.symbol_count || 0 + })); +})() +` }, + ], +}); diff --git a/clis/xueqiu/groups.yaml b/clis/xueqiu/groups.yaml deleted file mode 100644 index 62d6214a..00000000 --- a/clis/xueqiu/groups.yaml +++ /dev/null @@ -1,23 +0,0 @@ -site: xueqiu -name: groups -description: 获取雪球自选股分组列表(含模拟组合) -domain: xueqiu.com -browser: true - -pipeline: - - navigate: https://xueqiu.com - - evaluate: | - (async () => { - const resp = await fetch('https://stock.xueqiu.com/v5/stock/portfolio/list.json?category=1&size=20', {credentials: 'include'}); - if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?'); - const d = await resp.json(); - if (!d.data || !d.data.stocks) throw new Error('获取失败,可能未登录'); - - return d.data.stocks.map(g => ({ - pid: String(g.id), - name: g.name, - count: g.symbol_count || 0 - })); - })() - -columns: [pid, name, count] diff --git a/clis/xueqiu/hot-stock.ts b/clis/xueqiu/hot-stock.ts new file mode 100644 index 00000000..6a3eebba --- /dev/null +++ b/clis/xueqiu/hot-stock.ts @@ -0,0 +1,45 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'xueqiu', + name: 'hot-stock', + description: '获取雪球热门股票榜', + domain: 'xueqiu.com', + browser: true, + args: [ + { name: 'limit', type: 'int', default: 20, help: '返回数量,默认 20,最大 50' }, + { name: 'type', default: '10', help: '榜单类型 10=人气榜(默认) 12=关注榜' }, + ], + columns: ['rank', 'symbol', 'name', 'price', 'changePercent', 'heat'], + pipeline: [ + { navigate: 'https://xueqiu.com' }, + { evaluate: `(async () => { + const count = \${{ args.limit }}; + const type = \${{ args.type | json }}; + const resp = await fetch(\`https://stock.xueqiu.com/v5/stock/hot_stock/list.json?size=\${count}&type=\${type}\`, {credentials: 'include'}); + if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?'); + const d = await resp.json(); + if (!d.data || !d.data.items) throw new Error('获取失败'); + return d.data.items.map((s, i) => ({ + rank: i + 1, + symbol: s.symbol, + name: s.name, + price: s.current, + changePercent: s.percent != null ? s.percent.toFixed(2) + '%' : null, + heat: s.value, + rank_change: s.rank_change, + url: 'https://xueqiu.com/S/' + s.symbol + })); +})() +` }, + { map: { + rank: '${{ item.rank }}', + symbol: '${{ item.symbol }}', + name: '${{ item.name }}', + price: '${{ item.price }}', + changePercent: '${{ item.changePercent }}', + heat: '${{ item.heat }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/xueqiu/hot-stock.yaml b/clis/xueqiu/hot-stock.yaml deleted file mode 100644 index 9964c107..00000000 --- a/clis/xueqiu/hot-stock.yaml +++ /dev/null @@ -1,49 +0,0 @@ -site: xueqiu -name: hot-stock -description: 获取雪球热门股票榜 -domain: xueqiu.com -browser: true - -args: - limit: - type: int - default: 20 - description: 返回数量,默认 20,最大 50 - type: - type: str - default: "10" - description: 榜单类型 10=人气榜(默认) 12=关注榜 - -pipeline: - - navigate: https://xueqiu.com - - evaluate: | - (async () => { - const count = ${{ args.limit }}; - const type = ${{ args.type | json }}; - const resp = await fetch(`https://stock.xueqiu.com/v5/stock/hot_stock/list.json?size=${count}&type=${type}`, {credentials: 'include'}); - if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?'); - const d = await resp.json(); - if (!d.data || !d.data.items) throw new Error('获取失败'); - return d.data.items.map((s, i) => ({ - rank: i + 1, - symbol: s.symbol, - name: s.name, - price: s.current, - changePercent: s.percent != null ? s.percent.toFixed(2) + '%' : null, - heat: s.value, - rank_change: s.rank_change, - url: 'https://xueqiu.com/S/' + s.symbol - })); - })() - - - map: - rank: ${{ item.rank }} - symbol: ${{ item.symbol }} - name: ${{ item.name }} - price: ${{ item.price }} - changePercent: ${{ item.changePercent }} - heat: ${{ item.heat }} - - - limit: ${{ args.limit }} - -columns: [rank, symbol, name, price, changePercent, heat] diff --git a/clis/xueqiu/hot.ts b/clis/xueqiu/hot.ts new file mode 100644 index 00000000..e8555de3 --- /dev/null +++ b/clis/xueqiu/hot.ts @@ -0,0 +1,45 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'xueqiu', + name: 'hot', + description: '获取雪球热门动态', + domain: 'xueqiu.com', + browser: true, + args: [ + { name: 'limit', type: 'int', default: 20, help: '返回数量,默认 20,最大 50' }, + ], + columns: ['rank', 'author', 'text', 'likes', 'url'], + pipeline: [ + { navigate: 'https://xueqiu.com' }, + { evaluate: `(async () => { + const resp = await fetch('https://xueqiu.com/statuses/hot/listV3.json?source=hot&page=1', {credentials: 'include'}); + if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?'); + const d = await resp.json(); + const list = d.list || []; + + const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim(); + return list.map((item, i) => { + const user = item.user || {}; + return { + rank: i + 1, + text: strip(item.description).substring(0, 200), + url: 'https://xueqiu.com/' + user.id + '/' + item.id, + author: user.screen_name, + likes: item.fav_count, + retweets: item.retweet_count, + replies: item.reply_count + }; + }); +})() +` }, + { map: { + rank: '${{ item.rank }}', + author: '${{ item.author }}', + text: '${{ item.text }}', + likes: '${{ item.likes }}', + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/xueqiu/hot.yaml b/clis/xueqiu/hot.yaml deleted file mode 100644 index c7370acd..00000000 --- a/clis/xueqiu/hot.yaml +++ /dev/null @@ -1,46 +0,0 @@ -site: xueqiu -name: hot -description: 获取雪球热门动态 -domain: xueqiu.com -browser: true - -args: - limit: - type: int - default: 20 - description: 返回数量,默认 20,最大 50 - -pipeline: - - navigate: https://xueqiu.com - - evaluate: | - (async () => { - const resp = await fetch('https://xueqiu.com/statuses/hot/listV3.json?source=hot&page=1', {credentials: 'include'}); - if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?'); - const d = await resp.json(); - const list = d.list || []; - - const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim(); - return list.map((item, i) => { - const user = item.user || {}; - return { - rank: i + 1, - text: strip(item.description).substring(0, 200), - url: 'https://xueqiu.com/' + user.id + '/' + item.id, - author: user.screen_name, - likes: item.fav_count, - retweets: item.retweet_count, - replies: item.reply_count - }; - }); - })() - - - map: - rank: ${{ item.rank }} - author: ${{ item.author }} - text: ${{ item.text }} - likes: ${{ item.likes }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [rank, author, text, likes, url] diff --git a/clis/xueqiu/kline.ts b/clis/xueqiu/kline.ts new file mode 100644 index 00000000..d3b95396 --- /dev/null +++ b/clis/xueqiu/kline.ts @@ -0,0 +1,65 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'xueqiu', + name: 'kline', + description: '获取雪球股票K线(历史行情)数据', + domain: 'xueqiu.com', + browser: true, + args: [ + { + name: 'symbol', + required: true, + positional: true, + help: '股票代码,如 SH600519、SZ000858、AAPL', + }, + { name: 'days', type: 'int', default: 14, help: '回溯天数(默认14天)' }, + ], + columns: ['date', 'open', 'high', 'low', 'close', 'volume'], + pipeline: [ + { navigate: 'https://xueqiu.com' }, + { evaluate: `(async () => { + const symbol = (\${{ args.symbol | json }} || '').toUpperCase(); + const days = parseInt(\${{ args.days | json }}) || 14; + if (!symbol) throw new Error('Missing argument: symbol'); + + // begin = now minus days (for count=-N, returns N items ending at begin) + const beginTs = Date.now(); + const resp = await fetch('https://stock.xueqiu.com/v5/stock/chart/kline.json?symbol=' + encodeURIComponent(symbol) + '&begin=' + beginTs + '&period=day&type=before&count=-' + days, {credentials: 'include'}); + if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?'); + const d = await resp.json(); + + if (!d.data || !d.data.item || d.data.item.length === 0) return []; + + const columns = d.data.column || []; + const items = d.data.item || []; + const colIdx = {}; + columns.forEach((name, i) => { colIdx[name] = i; }); + + function fmt(v) { return v == null ? null : v; } + + return items.map(row => ({ + date: colIdx.timestamp != null ? new Date(row[colIdx.timestamp]).toISOString().split('T')[0] : null, + open: fmt(row[colIdx.open]), + high: fmt(row[colIdx.high]), + low: fmt(row[colIdx.low]), + close: fmt(row[colIdx.close]), + volume: fmt(row[colIdx.volume]), + amount: fmt(row[colIdx.amount]), + chg: fmt(row[colIdx.chg]), + percent: fmt(row[colIdx.percent]), + symbol: symbol + })); +})() +` }, + { map: { + date: '${{ item.date }}', + open: '${{ item.open }}', + high: '${{ item.high }}', + low: '${{ item.low }}', + close: '${{ item.close }}', + volume: '${{ item.volume }}', + percent: '${{ item.percent }}', + } }, + ], +}); diff --git a/clis/xueqiu/kline.yaml b/clis/xueqiu/kline.yaml deleted file mode 100644 index fa63d163..00000000 --- a/clis/xueqiu/kline.yaml +++ /dev/null @@ -1,65 +0,0 @@ -site: xueqiu -name: kline -description: 获取雪球股票K线(历史行情)数据 -domain: xueqiu.com -browser: true - -args: - symbol: - positional: true - type: str - required: true - description: 股票代码,如 SH600519、SZ000858、AAPL - days: - type: int - default: 14 - description: 回溯天数(默认14天) - -pipeline: - - navigate: https://xueqiu.com - - - evaluate: | - (async () => { - const symbol = (${{ args.symbol | json }} || '').toUpperCase(); - const days = parseInt(${{ args.days | json }}) || 14; - if (!symbol) throw new Error('Missing argument: symbol'); - - // begin = now minus days (for count=-N, returns N items ending at begin) - const beginTs = Date.now(); - const resp = await fetch('https://stock.xueqiu.com/v5/stock/chart/kline.json?symbol=' + encodeURIComponent(symbol) + '&begin=' + beginTs + '&period=day&type=before&count=-' + days, {credentials: 'include'}); - if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?'); - const d = await resp.json(); - - if (!d.data || !d.data.item || d.data.item.length === 0) return []; - - const columns = d.data.column || []; - const items = d.data.item || []; - const colIdx = {}; - columns.forEach((name, i) => { colIdx[name] = i; }); - - function fmt(v) { return v == null ? null : v; } - - return items.map(row => ({ - date: colIdx.timestamp != null ? new Date(row[colIdx.timestamp]).toISOString().split('T')[0] : null, - open: fmt(row[colIdx.open]), - high: fmt(row[colIdx.high]), - low: fmt(row[colIdx.low]), - close: fmt(row[colIdx.close]), - volume: fmt(row[colIdx.volume]), - amount: fmt(row[colIdx.amount]), - chg: fmt(row[colIdx.chg]), - percent: fmt(row[colIdx.percent]), - symbol: symbol - })); - })() - - - map: - date: ${{ item.date }} - open: ${{ item.open }} - high: ${{ item.high }} - low: ${{ item.low }} - close: ${{ item.close }} - volume: ${{ item.volume }} - percent: ${{ item.percent }} - -columns: [date, open, high, low, close, volume] diff --git a/clis/xueqiu/search.ts b/clis/xueqiu/search.ts new file mode 100644 index 00000000..bd940f91 --- /dev/null +++ b/clis/xueqiu/search.ts @@ -0,0 +1,50 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'xueqiu', + name: 'search', + description: '搜索雪球股票(代码或名称)', + domain: 'xueqiu.com', + browser: true, + args: [ + { name: 'query', required: true, positional: true, help: '搜索关键词,如 茅台、AAPL、腾讯' }, + { name: 'limit', type: 'int', default: 10, help: '返回数量,默认 10' }, + ], + columns: ['symbol', 'name', 'exchange', 'price', 'changePercent', 'url'], + pipeline: [ + { navigate: 'https://xueqiu.com' }, + { evaluate: `(async () => { + const query = \${{ args.query | json }}; + const count = \${{ args.limit }}; + const resp = await fetch(\`https://xueqiu.com/stock/search.json?code=\${encodeURIComponent(query)}&size=\${count}\`, {credentials: 'include'}); + if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?'); + const d = await resp.json(); + return (d.stocks || []).map(s => { + let symbol = ''; + if (s.exchange === 'SH' || s.exchange === 'SZ' || s.exchange === 'BJ') { + symbol = s.code.startsWith(s.exchange) ? s.code : s.exchange + s.code; + } else { + symbol = s.code; + } + return { + symbol: symbol, + name: s.name, + exchange: s.exchange, + price: s.current, + changePercent: s.percentage != null ? s.percentage.toFixed(2) + '%' : null, + url: 'https://xueqiu.com/S/' + symbol + }; + }); +})() +` }, + { map: { + symbol: '${{ item.symbol }}', + name: '${{ item.name }}', + exchange: '${{ item.exchange }}', + price: '${{ item.price }}', + changePercent: '${{ item.changePercent }}', + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/xueqiu/search.yaml b/clis/xueqiu/search.yaml deleted file mode 100644 index 4df1b877..00000000 --- a/clis/xueqiu/search.yaml +++ /dev/null @@ -1,55 +0,0 @@ -site: xueqiu -name: search -description: 搜索雪球股票(代码或名称) -domain: xueqiu.com -browser: true - -args: - query: - positional: true - type: str - required: true - description: 搜索关键词,如 茅台、AAPL、腾讯 - limit: - type: int - default: 10 - description: 返回数量,默认 10 - -pipeline: - - navigate: https://xueqiu.com - - evaluate: | - (async () => { - const query = ${{ args.query | json }}; - const count = ${{ args.limit }}; - const resp = await fetch(`https://xueqiu.com/stock/search.json?code=${encodeURIComponent(query)}&size=${count}`, {credentials: 'include'}); - if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?'); - const d = await resp.json(); - return (d.stocks || []).map(s => { - let symbol = ''; - if (s.exchange === 'SH' || s.exchange === 'SZ' || s.exchange === 'BJ') { - symbol = s.code.startsWith(s.exchange) ? s.code : s.exchange + s.code; - } else { - symbol = s.code; - } - return { - symbol: symbol, - name: s.name, - exchange: s.exchange, - price: s.current, - changePercent: s.percentage != null ? s.percentage.toFixed(2) + '%' : null, - url: 'https://xueqiu.com/S/' + symbol - }; - }); - })() - - - map: - symbol: ${{ item.symbol }} - name: ${{ item.name }} - exchange: ${{ item.exchange }} - price: ${{ item.price }} - changePercent: ${{ item.changePercent }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [symbol, name, exchange, price, changePercent, url] diff --git a/clis/xueqiu/stock.ts b/clis/xueqiu/stock.ts new file mode 100644 index 00000000..cf79c106 --- /dev/null +++ b/clis/xueqiu/stock.ts @@ -0,0 +1,73 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'xueqiu', + name: 'stock', + description: '获取雪球股票实时行情', + domain: 'xueqiu.com', + browser: true, + args: [ + { + name: 'symbol', + required: true, + positional: true, + help: '股票代码,如 SH600519、SZ000858、AAPL、00700', + }, + ], + columns: ['name', 'symbol', 'price', 'changePercent', 'marketCap'], + pipeline: [ + { navigate: 'https://xueqiu.com' }, + { evaluate: `(async () => { + const symbol = (\${{ args.symbol | json }} || '').toUpperCase(); + if (!symbol) throw new Error('Missing argument: symbol'); + const resp = await fetch(\`https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol=\${encodeURIComponent(symbol)}\`, {credentials: 'include'}); + if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?'); + const d = await resp.json(); + if (!d.data || !d.data.items || d.data.items.length === 0) throw new Error('未找到股票: ' + symbol); + + function fmtAmount(v) { + if (v == null) return null; + if (Math.abs(v) >= 1e12) return (v / 1e12).toFixed(2) + '万亿'; + if (Math.abs(v) >= 1e8) return (v / 1e8).toFixed(2) + '亿'; + if (Math.abs(v) >= 1e4) return (v / 1e4).toFixed(2) + '万'; + return v.toString(); + } + + const item = d.data.items[0]; + const q = item.quote || {}; + const m = item.market || {}; + + return [{ + name: q.name, + symbol: q.symbol, + exchange: q.exchange, + currency: q.currency, + price: q.current, + change: q.chg, + changePercent: q.percent != null ? q.percent.toFixed(2) + '%' : null, + open: q.open, + high: q.high, + low: q.low, + prevClose: q.last_close, + amplitude: q.amplitude != null ? q.amplitude.toFixed(2) + '%' : null, + volume: q.volume, + amount: fmtAmount(q.amount), + turnover_rate: q.turnover_rate != null ? q.turnover_rate.toFixed(2) + '%' : null, + marketCap: fmtAmount(q.market_capital), + floatMarketCap: fmtAmount(q.float_market_capital), + ytdPercent: q.current_year_percent != null ? q.current_year_percent.toFixed(2) + '%' : null, + market_status: m.status || null, + time: q.timestamp ? new Date(q.timestamp).toISOString() : null, + url: 'https://xueqiu.com/S/' + q.symbol + }]; +})() +` }, + { map: { + name: '${{ item.name }}', + symbol: '${{ item.symbol }}', + price: '${{ item.price }}', + changePercent: '${{ item.changePercent }}', + marketCap: '${{ item.marketCap }}', + } }, + ], +}); diff --git a/clis/xueqiu/stock.yaml b/clis/xueqiu/stock.yaml deleted file mode 100644 index ab5f5791..00000000 --- a/clis/xueqiu/stock.yaml +++ /dev/null @@ -1,69 +0,0 @@ -site: xueqiu -name: stock -description: 获取雪球股票实时行情 -domain: xueqiu.com -browser: true - -args: - symbol: - positional: true - type: str - required: true - description: 股票代码,如 SH600519、SZ000858、AAPL、00700 - -pipeline: - - navigate: https://xueqiu.com - - evaluate: | - (async () => { - const symbol = (${{ args.symbol | json }} || '').toUpperCase(); - if (!symbol) throw new Error('Missing argument: symbol'); - const resp = await fetch(`https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol=${encodeURIComponent(symbol)}`, {credentials: 'include'}); - if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?'); - const d = await resp.json(); - if (!d.data || !d.data.items || d.data.items.length === 0) throw new Error('未找到股票: ' + symbol); - - function fmtAmount(v) { - if (v == null) return null; - if (Math.abs(v) >= 1e12) return (v / 1e12).toFixed(2) + '万亿'; - if (Math.abs(v) >= 1e8) return (v / 1e8).toFixed(2) + '亿'; - if (Math.abs(v) >= 1e4) return (v / 1e4).toFixed(2) + '万'; - return v.toString(); - } - - const item = d.data.items[0]; - const q = item.quote || {}; - const m = item.market || {}; - - return [{ - name: q.name, - symbol: q.symbol, - exchange: q.exchange, - currency: q.currency, - price: q.current, - change: q.chg, - changePercent: q.percent != null ? q.percent.toFixed(2) + '%' : null, - open: q.open, - high: q.high, - low: q.low, - prevClose: q.last_close, - amplitude: q.amplitude != null ? q.amplitude.toFixed(2) + '%' : null, - volume: q.volume, - amount: fmtAmount(q.amount), - turnover_rate: q.turnover_rate != null ? q.turnover_rate.toFixed(2) + '%' : null, - marketCap: fmtAmount(q.market_capital), - floatMarketCap: fmtAmount(q.float_market_capital), - ytdPercent: q.current_year_percent != null ? q.current_year_percent.toFixed(2) + '%' : null, - market_status: m.status || null, - time: q.timestamp ? new Date(q.timestamp).toISOString() : null, - url: 'https://xueqiu.com/S/' + q.symbol - }]; - })() - - - map: - name: ${{ item.name }} - symbol: ${{ item.symbol }} - price: ${{ item.price }} - changePercent: ${{ item.changePercent }} - marketCap: ${{ item.marketCap }} - -columns: [name, symbol, price, changePercent, marketCap] diff --git a/clis/xueqiu/watchlist.ts b/clis/xueqiu/watchlist.ts new file mode 100644 index 00000000..fb7608ee --- /dev/null +++ b/clis/xueqiu/watchlist.ts @@ -0,0 +1,46 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'xueqiu', + name: 'watchlist', + description: '获取雪球自选股/模拟组合股票列表', + domain: 'xueqiu.com', + browser: true, + args: [ + { + name: 'pid', + default: '-1', + help: '分组ID:-1=全部(默认) -4=模拟 -5=沪深 -6=美股 -7=港股 -10=实盘 0=持仓(通过 xueqiu groups 获取)', + }, + { name: 'limit', type: 'int', default: 100, help: '默认 100' }, + ], + columns: ['symbol', 'name', 'price', 'changePercent'], + pipeline: [ + { navigate: 'https://xueqiu.com' }, + { evaluate: `(async () => { + const pid = \${{ args.pid | json }} || '-1'; + const resp = await fetch(\`https://stock.xueqiu.com/v5/stock/portfolio/stock/list.json?size=100&category=1&pid=\${encodeURIComponent(pid)}\`, {credentials: 'include'}); + if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?'); + const d = await resp.json(); + if (!d.data || !d.data.stocks) throw new Error('获取失败,可能未登录'); + + return d.data.stocks.map(s => ({ + symbol: s.symbol, + name: s.name, + price: s.current, + change: s.chg, + changePercent: s.percent != null ? s.percent.toFixed(2) + '%' : null, + volume: s.volume, + url: 'https://xueqiu.com/S/' + s.symbol + })); +})() +` }, + { map: { + symbol: '${{ item.symbol }}', + name: '${{ item.name }}', + price: '${{ item.price }}', + changePercent: '${{ item.changePercent }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/xueqiu/watchlist.yaml b/clis/xueqiu/watchlist.yaml deleted file mode 100644 index b20eb9e1..00000000 --- a/clis/xueqiu/watchlist.yaml +++ /dev/null @@ -1,46 +0,0 @@ -site: xueqiu -name: watchlist -description: 获取雪球自选股/模拟组合股票列表 -domain: xueqiu.com -browser: true - -args: - pid: - type: str - default: "-1" - description: "分组ID:-1=全部(默认) -4=模拟 -5=沪深 -6=美股 -7=港股 -10=实盘 0=持仓(通过 xueqiu groups 获取)" - limit: - type: int - default: 100 - description: 默认 100 - -pipeline: - - navigate: https://xueqiu.com - - evaluate: | - (async () => { - const pid = ${{ args.pid | json }} || '-1'; - const resp = await fetch(`https://stock.xueqiu.com/v5/stock/portfolio/stock/list.json?size=100&category=1&pid=${encodeURIComponent(pid)}`, {credentials: 'include'}); - if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?'); - const d = await resp.json(); - if (!d.data || !d.data.stocks) throw new Error('获取失败,可能未登录'); - - return d.data.stocks.map(s => ({ - symbol: s.symbol, - name: s.name, - price: s.current, - change: s.chg, - changePercent: s.percent != null ? s.percent.toFixed(2) + '%' : null, - volume: s.volume, - url: 'https://xueqiu.com/S/' + s.symbol - })); - })() - - - map: - symbol: ${{ item.symbol }} - name: ${{ item.name }} - price: ${{ item.price }} - changePercent: ${{ item.changePercent }} - - - limit: ${{ args.limit }} - -columns: [symbol, name, price, changePercent] diff --git a/clis/zhihu/hot.ts b/clis/zhihu/hot.ts new file mode 100644 index 00000000..717900a3 --- /dev/null +++ b/clis/zhihu/hot.ts @@ -0,0 +1,44 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'zhihu', + name: 'hot', + description: '知乎热榜', + domain: 'www.zhihu.com', + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of items to return' }, + ], + columns: ['rank', 'title', 'heat', 'answers'], + pipeline: [ + { navigate: 'https://www.zhihu.com' }, + { evaluate: `(async () => { + const res = await fetch('https://www.zhihu.com/api/v3/feed/topstory/hot-lists/total?limit=50', { + credentials: 'include' + }); + const text = await res.text(); + const d = JSON.parse( + text.replace(/("id"\\s*:\\s*)(\\d{16,})/g, '$1"$2"') + ); + return (d?.data || []).map((item) => { + const t = item.target || {}; + const questionId = t.id == null ? '' : String(t.id); + return { + title: t.title, + url: 'https://www.zhihu.com/question/' + questionId, + answer_count: t.answer_count, + follower_count: t.follower_count, + heat: item.detail_text || '', + }; + }); +})() +` }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + heat: '${{ item.heat }}', + answers: '${{ item.answer_count }}', + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/zhihu/hot.yaml b/clis/zhihu/hot.yaml deleted file mode 100644 index 1802f960..00000000 --- a/clis/zhihu/hot.yaml +++ /dev/null @@ -1,46 +0,0 @@ -site: zhihu -name: hot -description: 知乎热榜 -domain: www.zhihu.com - -args: - limit: - type: int - default: 20 - description: Number of items to return - -pipeline: - - navigate: https://www.zhihu.com - - - evaluate: | - (async () => { - const res = await fetch('https://www.zhihu.com/api/v3/feed/topstory/hot-lists/total?limit=50', { - credentials: 'include' - }); - const text = await res.text(); - const d = JSON.parse( - text.replace(/("id"\s*:\s*)(\d{16,})/g, '$1"$2"') - ); - return (d?.data || []).map((item) => { - const t = item.target || {}; - const questionId = t.id == null ? '' : String(t.id); - return { - title: t.title, - url: 'https://www.zhihu.com/question/' + questionId, - answer_count: t.answer_count, - follower_count: t.follower_count, - heat: item.detail_text || '', - }; - }); - })() - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - heat: ${{ item.heat }} - answers: ${{ item.answer_count }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [rank, title, heat, answers] diff --git a/clis/zhihu/search.ts b/clis/zhihu/search.ts new file mode 100644 index 00000000..ababc19f --- /dev/null +++ b/clis/zhihu/search.ts @@ -0,0 +1,53 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'zhihu', + name: 'search', + description: '知乎搜索', + domain: 'www.zhihu.com', + args: [ + { name: 'query', required: true, positional: true, help: 'Search query' }, + { name: 'limit', type: 'int', default: 10, help: 'Number of results' }, + ], + columns: ['rank', 'title', 'type', 'author', 'votes', 'url'], + pipeline: [ + { navigate: 'https://www.zhihu.com' }, + { evaluate: `(async () => { + const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(//g, '').replace(/<\\/em>/g, '').trim(); + const keyword = \${{ args.query | json }}; + const limit = \${{ args.limit }}; + const res = await fetch('https://www.zhihu.com/api/v4/search_v3?q=' + encodeURIComponent(keyword) + '&t=general&offset=0&limit=' + limit, { + credentials: 'include' + }); + const d = await res.json(); + return (d?.data || []) + .filter(item => item.type === 'search_result') + .map(item => { + const obj = item.object || {}; + const q = obj.question || {}; + return { + type: obj.type, + title: strip(obj.title || q.name || ''), + excerpt: strip(obj.excerpt || '').substring(0, 100), + author: obj.author?.name || '', + votes: obj.voteup_count || 0, + url: obj.type === 'answer' + ? 'https://www.zhihu.com/question/' + q.id + '/answer/' + obj.id + : obj.type === 'article' + ? 'https://zhuanlan.zhihu.com/p/' + obj.id + : 'https://www.zhihu.com/question/' + obj.id, + }; + }); +})() +` }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + type: '${{ item.type }}', + author: '${{ item.author }}', + votes: '${{ item.votes }}', + url: '${{ item.url }}', + } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/clis/zhihu/search.yaml b/clis/zhihu/search.yaml deleted file mode 100644 index 38483823..00000000 --- a/clis/zhihu/search.yaml +++ /dev/null @@ -1,59 +0,0 @@ -site: zhihu -name: search -description: 知乎搜索 -domain: www.zhihu.com - -args: - query: - positional: true - type: str - required: true - description: Search query - limit: - type: int - default: 10 - description: Number of results - -pipeline: - - navigate: https://www.zhihu.com - - - evaluate: | - (async () => { - const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(//g, '').replace(/<\/em>/g, '').trim(); - const keyword = ${{ args.query | json }}; - const limit = ${{ args.limit }}; - const res = await fetch('https://www.zhihu.com/api/v4/search_v3?q=' + encodeURIComponent(keyword) + '&t=general&offset=0&limit=' + limit, { - credentials: 'include' - }); - const d = await res.json(); - return (d?.data || []) - .filter(item => item.type === 'search_result') - .map(item => { - const obj = item.object || {}; - const q = obj.question || {}; - return { - type: obj.type, - title: strip(obj.title || q.name || ''), - excerpt: strip(obj.excerpt || '').substring(0, 100), - author: obj.author?.name || '', - votes: obj.voteup_count || 0, - url: obj.type === 'answer' - ? 'https://www.zhihu.com/question/' + q.id + '/answer/' + obj.id - : obj.type === 'article' - ? 'https://zhuanlan.zhihu.com/p/' + obj.id - : 'https://www.zhihu.com/question/' + obj.id, - }; - }); - })() - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - type: ${{ item.type }} - author: ${{ item.author }} - votes: ${{ item.votes }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [rank, title, type, author, votes, url] diff --git a/src/build-manifest.test.ts b/src/build-manifest.test.ts index 33bb9691..0f4e48da 100644 --- a/src/build-manifest.test.ts +++ b/src/build-manifest.test.ts @@ -3,7 +3,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { cli, getRegistry, Strategy } from './registry.js'; -import { loadTsManifestEntries, shouldReplaceManifestEntry } from './build-manifest.js'; +import { loadTsManifestEntries } from './build-manifest.js'; describe('manifest helper rules', () => { const tempDirs: string[] = []; @@ -14,52 +14,6 @@ describe('manifest helper rules', () => { } }); - it('prefers TS adapters over duplicate YAML adapters', () => { - expect(shouldReplaceManifestEntry( - { - site: 'demo', - name: 'search', - description: 'yaml', - strategy: 'public', - browser: false, - args: [], - type: 'yaml', - }, - { - site: 'demo', - name: 'search', - description: 'ts', - strategy: 'public', - browser: false, - args: [], - type: 'ts', - modulePath: 'demo/search.js', - }, - )).toBe(true); - - expect(shouldReplaceManifestEntry( - { - site: 'demo', - name: 'search', - description: 'ts', - strategy: 'public', - browser: false, - args: [], - type: 'ts', - modulePath: 'demo/search.js', - }, - { - site: 'demo', - name: 'search', - description: 'yaml', - strategy: 'public', - browser: false, - args: [], - type: 'yaml', - }, - )).toBe(false); - }); - it('skips TS files that do not register a cli', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-')); tempDirs.push(dir); diff --git a/src/build-manifest.ts b/src/build-manifest.ts index bf138970..02496a1e 100644 --- a/src/build-manifest.ts +++ b/src/build-manifest.ts @@ -2,8 +2,8 @@ /** * Build-time CLI manifest compiler. * - * Scans all YAML/TS CLI definitions and pre-compiles them into a single - * manifest.json for instant cold-start registration (no runtime YAML parsing). + * Scans all TS CLI definitions and pre-compiles them into a single + * manifest.json for instant cold-start registration. * * Usage: npx tsx src/build-manifest.ts * Output: cli-manifest.json at the package root @@ -12,7 +12,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import yaml from 'js-yaml'; import { getErrorMessage } from './errors.js'; import { fullName, getRegistry, type CliCommand } from './registry.js'; import { findPackageRoot, getCliManifestPath } from './package-paths.js'; @@ -44,9 +43,8 @@ export interface ManifestEntry { timeout?: number; deprecated?: boolean | string; replacedBy?: string; - /** 'yaml' or 'ts' — determines how executeCommand loads the handler */ - type: 'yaml' | 'ts'; - /** Relative path from clis/ dir, e.g. 'bilibili/hot.yaml' or 'bilibili/search.js' */ + type: 'ts'; + /** Relative path from clis/ dir, e.g. 'bilibili/search.js' */ modulePath?: string; /** Relative path to the original source file from clis/ dir (for YAML: 'site/cmd.yaml') */ sourceFile?: string; @@ -54,8 +52,6 @@ export interface ManifestEntry { navigateBefore?: boolean | string; } -import { type YamlCliDefinition, parseYamlArgs } from './yaml-schema.js'; - import { isRecord } from './utils.js'; const CLI_MODULE_PATTERN = /\bcli\s*\(/; @@ -107,45 +103,6 @@ function toManifestEntry(cmd: CliCommand, modulePath: string, sourceFile?: strin }; } -function scanYaml(filePath: string, site: string): ManifestEntry | null { - try { - const raw = fs.readFileSync(filePath, 'utf-8'); - const def = yaml.load(raw) as YamlCliDefinition | null; - if (!isRecord(def)) return null; - const cliDef = def as YamlCliDefinition; - - const strategyStr = cliDef.strategy ?? (cliDef.browser === false ? 'public' : 'cookie'); - const strategy = strategyStr.toUpperCase(); - const browser = cliDef.browser ?? (strategy !== 'PUBLIC'); - - const args = parseYamlArgs(cliDef.args); - - return { - site: cliDef.site ?? site, - name: cliDef.name ?? path.basename(filePath, path.extname(filePath)), - description: cliDef.description ?? '', - domain: cliDef.domain, - strategy: strategy.toLowerCase(), - browser, - aliases: isRecord(cliDef) && Array.isArray((cliDef as Record).aliases) - ? ((cliDef as Record).aliases as unknown[]).filter((value): value is string => typeof value === 'string') - : undefined, - args, - columns: cliDef.columns, - pipeline: cliDef.pipeline, - timeout: cliDef.timeout, - deprecated: (cliDef as Record).deprecated as boolean | string | undefined, - replacedBy: (cliDef as Record).replacedBy as string | undefined, - type: 'yaml', - sourceFile: path.relative(CLIS_DIR, filePath), - navigateBefore: cliDef.navigateBefore, - }; - } catch (err) { - process.stderr.write(`Warning: failed to parse ${filePath}: ${getErrorMessage(err)}\n`); - return null; - } -} - export async function loadTsManifestEntries( filePath: string, site: string, @@ -192,15 +149,6 @@ export async function loadTsManifestEntries( } } -/** - * When both YAML and TS adapters exist for the same site/name, - * prefer the TS version (it self-registers and typically has richer logic). - */ -export function shouldReplaceManifestEntry(current: ManifestEntry, next: ManifestEntry): boolean { - if (current.type === next.type) return false; - return current.type === 'yaml' && next.type === 'ts'; -} - export async function buildManifest(): Promise { const manifest = new Map(); @@ -209,33 +157,15 @@ export async function buildManifest(): Promise { const siteDir = path.join(CLIS_DIR, site); if (!fs.statSync(siteDir).isDirectory()) continue; for (const file of fs.readdirSync(siteDir)) { - const filePath = path.join(siteDir, file); - if (file.endsWith('.yaml') || file.endsWith('.yml')) { - const entry = scanYaml(filePath, site); - if (entry) { - const key = `${entry.site}/${entry.name}`; - const existing = manifest.get(key); - if (!existing || shouldReplaceManifestEntry(existing, entry)) { - if (existing && existing.type !== entry.type) { - process.stderr.write(`⚠️ Duplicate adapter ${key}: ${existing.type} superseded by ${entry.type}\n`); - } - manifest.set(key, entry); - } - } - } else if ( + if ( (file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts') && file !== 'index.ts') || (file.endsWith('.js') && !file.endsWith('.d.js') && !file.endsWith('.test.js') && file !== 'index.js') ) { + const filePath = path.join(siteDir, file); const entries = await loadTsManifestEntries(filePath, site); for (const entry of entries) { const key = `${entry.site}/${entry.name}`; - const existing = manifest.get(key); - if (!existing || shouldReplaceManifestEntry(existing, entry)) { - if (existing && existing.type !== entry.type) { - process.stderr.write(`⚠️ Duplicate adapter ${key}: ${existing.type} superseded by ${entry.type}\n`); - } - manifest.set(key, entry); - } + manifest.set(key, entry); } } } @@ -250,9 +180,7 @@ async function main(): Promise { fs.mkdirSync(path.dirname(OUTPUT), { recursive: true }); fs.writeFileSync(OUTPUT, JSON.stringify(manifest, null, 2)); - const yamlCount = manifest.filter(e => e.type === 'yaml').length; - const tsCount = manifest.filter(e => e.type === 'ts').length; - console.log(`✅ Manifest compiled: ${manifest.length} entries (${yamlCount} YAML, ${tsCount} TS) → ${OUTPUT}`); + console.log(`✅ Manifest compiled: ${manifest.length} entries → ${OUTPUT}`); // Restore executable permissions on bin entries. // tsc does not preserve the +x bit, so after a clean rebuild the CLI diff --git a/src/discovery.ts b/src/discovery.ts index 4c506497..fabe9eca 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -1,10 +1,10 @@ /** - * CLI discovery: finds YAML/TS CLI definitions and registers them. + * CLI discovery: finds TS CLI definitions and registers them. * * Supports two modes: * 1. FAST PATH (manifest): If a pre-compiled cli-manifest.json exists, - * registers all YAML commands instantly without runtime YAML parsing. - * TS modules are loaded lazily only when their command is executed. + * registers commands instantly. TS modules are loaded lazily only + * when their command is executed. * 2. FALLBACK (filesystem scan): Traditional runtime discovery for development. */ @@ -12,8 +12,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import yaml from 'js-yaml'; -import { type CliCommand, type InternalCliCommand, type Arg, Strategy, registerCommand } from './registry.js'; +import { type InternalCliCommand, Strategy, registerCommand } from './registry.js'; import { getErrorMessage } from './errors.js'; import { log } from './logger.js'; import type { ManifestEntry } from './build-manifest.js'; @@ -28,16 +27,12 @@ export const PLUGINS_DIR = path.join(USER_OPENCLI_DIR, 'plugins'); /** Matches files that register commands via cli() or lifecycle hooks */ const PLUGIN_MODULE_PATTERN = /\b(?:cli|onStartup|onBeforeExecute|onAfterExecute)\s*\(/; -import { type YamlCliDefinition, parseYamlArgs } from './yaml-schema.js'; - function parseStrategy(rawStrategy: string | undefined, fallback: Strategy = Strategy.COOKIE): Strategy { if (!rawStrategy) return fallback; const key = rawStrategy.toUpperCase() as keyof typeof Strategy; return Strategy[key] ?? fallback; } -import { isRecord } from './utils.js'; - const PACKAGE_ROOT = findPackageRoot(fileURLToPath(import.meta.url)); /** @@ -141,7 +136,6 @@ export async function discoverClis(...dirs: string[]): Promise { /** * Fast-path: register commands from pre-compiled manifest. - * YAML pipelines are inlined — zero YAML parsing at runtime. * TS modules are deferred — loaded lazily on first execution. */ async function loadFromManifest(manifestPath: string, clisDir: string): Promise { @@ -149,52 +143,29 @@ async function loadFromManifest(manifestPath: string, clisDir: string): Promise< const raw = await fs.promises.readFile(manifestPath, 'utf-8'); const manifest = JSON.parse(raw) as ManifestEntry[]; for (const entry of manifest) { - if (entry.type === 'yaml') { - // YAML pipelines fully inlined in manifest — register directly - const strategy = parseStrategy(entry.strategy); - const cmd: CliCommand = { - site: entry.site, - name: entry.name, - aliases: entry.aliases, - description: entry.description ?? '', - domain: entry.domain, - strategy, - browser: entry.browser, - args: entry.args ?? [], - columns: entry.columns, - pipeline: entry.pipeline, - timeoutSeconds: entry.timeout, - source: entry.sourceFile ? path.resolve(clisDir, entry.sourceFile) : `manifest:${entry.site}/${entry.name}`, - deprecated: entry.deprecated, - replacedBy: entry.replacedBy, - navigateBefore: entry.navigateBefore, - }; - registerCommand(cmd); - } else if (entry.type === 'ts' && entry.modulePath) { - // TS adapters: register a lightweight stub. - // The actual module is loaded lazily on first executeCommand(). - const strategy = parseStrategy(entry.strategy ?? 'cookie'); - const modulePath = path.resolve(clisDir, entry.modulePath); - const cmd: InternalCliCommand = { - site: entry.site, - name: entry.name, - aliases: entry.aliases, - description: entry.description ?? '', - domain: entry.domain, - strategy, - browser: entry.browser ?? true, - args: entry.args ?? [], - columns: entry.columns, - timeoutSeconds: entry.timeout, - source: entry.sourceFile ? path.resolve(clisDir, entry.sourceFile) : modulePath, - deprecated: entry.deprecated, - replacedBy: entry.replacedBy, - navigateBefore: entry.navigateBefore, - _lazy: true, - _modulePath: modulePath, - }; - registerCommand(cmd); - } + if (!entry.modulePath) continue; + const strategy = parseStrategy(entry.strategy ?? 'cookie'); + const modulePath = path.resolve(clisDir, entry.modulePath); + const cmd: InternalCliCommand = { + site: entry.site, + name: entry.name, + aliases: entry.aliases, + description: entry.description ?? '', + domain: entry.domain, + strategy, + browser: entry.browser ?? true, + args: entry.args ?? [], + columns: entry.columns, + pipeline: entry.pipeline, + timeoutSeconds: entry.timeout, + source: entry.sourceFile ? path.resolve(clisDir, entry.sourceFile) : modulePath, + deprecated: entry.deprecated, + replacedBy: entry.replacedBy, + navigateBefore: entry.navigateBefore, + _lazy: true, + _modulePath: modulePath, + }; + registerCommand(cmd); } return true; } catch (err) { @@ -218,9 +189,7 @@ async function discoverClisFromFs(dir: string): Promise { const files = await fs.promises.readdir(siteDir); await Promise.all(files.map(async (file) => { const filePath = path.join(siteDir, file); - if (file.endsWith('.yaml') || file.endsWith('.yml')) { - await registerYamlCli(filePath, site); - } else if ( + if ( (file.endsWith('.js') && !file.endsWith('.d.js')) || (file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts')) ) { @@ -234,47 +203,6 @@ async function discoverClisFromFs(dir: string): Promise { await Promise.all(sitePromises); } -async function registerYamlCli(filePath: string, defaultSite: string): Promise { - try { - const raw = await fs.promises.readFile(filePath, 'utf-8'); - const def = yaml.load(raw) as YamlCliDefinition | null; - if (!isRecord(def)) return; - const cliDef = def as YamlCliDefinition; - - const site = cliDef.site ?? defaultSite; - const name = cliDef.name ?? path.basename(filePath, path.extname(filePath)); - const strategyStr = cliDef.strategy ?? (cliDef.browser === false ? 'public' : 'cookie'); - const strategy = parseStrategy(strategyStr); - const browser = cliDef.browser ?? (strategy !== Strategy.PUBLIC); - - const args = parseYamlArgs(cliDef.args); - - const cmd: CliCommand = { - site, - name, - aliases: isRecord(cliDef) && Array.isArray((cliDef as Record).aliases) - ? ((cliDef as Record).aliases as unknown[]).filter((value): value is string => typeof value === 'string') - : undefined, - description: cliDef.description ?? '', - domain: cliDef.domain, - strategy, - browser, - args, - columns: cliDef.columns, - pipeline: cliDef.pipeline, - timeoutSeconds: cliDef.timeout, - source: filePath, - deprecated: (cliDef as Record).deprecated as boolean | string | undefined, - replacedBy: (cliDef as Record).replacedBy as string | undefined, - navigateBefore: cliDef.navigateBefore, - }; - - registerCommand(cmd); - } catch (err) { - log.warn(`Failed to load ${filePath}: ${getErrorMessage(err)}`); - } -} - /** * Discover and register plugins from ~/.opencli/plugins/. * Each subdirectory is treated as a plugin (site = directory name). @@ -291,7 +219,7 @@ export async function discoverPlugins(): Promise { } /** - * Flat scan: read yaml/ts files directly in a plugin directory. + * Flat scan: read ts/js files directly in a plugin directory. * Unlike discoverClisFromFs, this does NOT expect nested site subdirectories. */ async function discoverPluginDir(dir: string, site: string): Promise { @@ -299,9 +227,7 @@ async function discoverPluginDir(dir: string, site: string): Promise { const fileSet = new Set(files); await Promise.all(files.map(async (file) => { const filePath = path.join(dir, file); - if (file.endsWith('.yaml') || file.endsWith('.yml')) { - await registerYamlCli(filePath, site); - } else if (file.endsWith('.js') && !file.endsWith('.d.js')) { + if (file.endsWith('.js') && !file.endsWith('.d.js')) { if (!(await isCliModule(filePath))) return; await import(pathToFileURL(filePath).href).catch((err) => { log.warn(`Plugin ${site}/${file}: ${getErrorMessage(err)}`); diff --git a/src/engine.test.ts b/src/engine.test.ts index 2e3bdd7b..626f03f8 100644 --- a/src/engine.test.ts +++ b/src/engine.test.ts @@ -128,8 +128,7 @@ describe('discoverPlugins', () => { try { await fs.promises.rm(brokenSymlinkDir, { recursive: true, force: true }); } catch {} }); - it('discovers YAML plugins from ~/.opencli/plugins/', async () => { - // Create a simple YAML adapter in the plugins directory + it('ignores YAML files in plugin directories (YAML format removed)', async () => { await fs.promises.mkdir(testPluginDir, { recursive: true }); await fs.promises.writeFile(yamlPath, ` site: __test-plugin__ @@ -137,21 +136,13 @@ name: greeting description: Test plugin greeting strategy: public browser: false - -pipeline: - - evaluate: "() => [{ message: 'hello from plugin' }]" - -columns: [message] `); await discoverPlugins(); const registry = getRegistry(); const cmd = registry.get('__test-plugin__/greeting'); - expect(cmd).toBeDefined(); - expect(cmd!.site).toBe('__test-plugin__'); - expect(cmd!.name).toBe('greeting'); - expect(cmd!.description).toBe('Test plugin greeting'); + expect(cmd).toBeUndefined(); }); it('handles non-existent plugins directory gracefully', async () => { @@ -159,7 +150,7 @@ columns: [message] await expect(discoverPlugins()).resolves.not.toThrow(); }); - it('discovers YAML plugins from symlinked plugin directories', async () => { + it('ignores YAML files in symlinked plugin directories (YAML format removed)', async () => { await fs.promises.mkdir(PLUGINS_DIR, { recursive: true }); await fs.promises.mkdir(symlinkTargetDir, { recursive: true }); await fs.promises.writeFile(path.join(symlinkTargetDir, 'hello.yaml'), ` @@ -168,19 +159,13 @@ name: hello description: Test plugin greeting via symlink strategy: public browser: false - -pipeline: - - evaluate: "() => [{ message: 'hello from symlink plugin' }]" - -columns: [message] `); await fs.promises.symlink(symlinkTargetDir, symlinkPluginDir, 'dir'); await discoverPlugins(); const cmd = getRegistry().get('__test-plugin-symlink__/hello'); - expect(cmd).toBeDefined(); - expect(cmd!.description).toBe('Test plugin greeting via symlink'); + expect(cmd).toBeUndefined(); }); it('skips broken plugin symlinks without throwing', async () => { diff --git a/src/generate-verified.test.ts b/src/generate-verified.test.ts index 1704a503..c3164ebe 100644 --- a/src/generate-verified.test.ts +++ b/src/generate-verified.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import yaml from 'js-yaml'; import type { IPage } from './types.js'; const { @@ -123,8 +122,8 @@ describe('generateVerifiedFromUrl', () => { }); it('returns blocked with auth-too-complex when no PUBLIC/COOKIE probe succeeds', async () => { - const candidatePath = path.join(tempDir, 'hot.yaml'); - fs.writeFileSync(candidatePath, yaml.dump({ + const candidatePath = path.join(tempDir, 'hot.json'); + fs.writeFileSync(candidatePath, JSON.stringify({ site: 'demo', name: 'hot', description: 'demo hot', @@ -137,7 +136,7 @@ describe('generateVerifiedFromUrl', () => { { fetch: { url: 'https://demo.test/api/hot' } }, { select: 'data.items' }, ], - }, { sortKeys: false })); + }, null, 2)); mockExploreUrl.mockResolvedValue({ site: 'demo', @@ -199,10 +198,10 @@ describe('generateVerifiedFromUrl', () => { // ── Success outcomes ────────────────────────────────────────────────────── it('verifies the selected candidate in a single session and registers on success with sidecar metadata', async () => { - const hotPath = path.join(tempDir, 'hot.yaml'); - const searchPath = path.join(tempDir, 'search.yaml'); + const hotPath = path.join(tempDir, 'hot.json'); + const searchPath = path.join(tempDir, 'search.json'); - fs.writeFileSync(hotPath, yaml.dump({ + fs.writeFileSync(hotPath, JSON.stringify({ site: 'demo', name: 'hot', description: 'demo hot', @@ -219,9 +218,9 @@ describe('generateVerifiedFromUrl', () => { { map: { rank: '${{ index + 1 }}', title: '${{ item.title }}', url: '${{ item.url }}' } }, { limit: '${{ args.limit | default(20) }}' }, ], - }, { sortKeys: false })); + }, null, 2)); - fs.writeFileSync(searchPath, yaml.dump({ + fs.writeFileSync(searchPath, JSON.stringify({ site: 'demo', name: 'search', description: 'demo search', @@ -237,7 +236,7 @@ describe('generateVerifiedFromUrl', () => { { select: 'payload.items' }, { map: { title: '${{ item.title }}', url: '${{ item.url }}' } }, ], - }, { sortKeys: false })); + }, null, 2)); mockExploreUrl.mockResolvedValue({ site: 'demo', @@ -337,8 +336,8 @@ describe('generateVerifiedFromUrl', () => { }); it('writes verified artifact + sidecar metadata for --no-register success', async () => { - const candidatePath = path.join(tempDir, 'search.yaml'); - fs.writeFileSync(candidatePath, yaml.dump({ + const candidatePath = path.join(tempDir, 'search.json'); + fs.writeFileSync(candidatePath, JSON.stringify({ site: 'demo', name: 'search', description: 'demo search', @@ -354,7 +353,7 @@ describe('generateVerifiedFromUrl', () => { { select: 'payload.items' }, { map: { title: '${{ item.title }}', url: '${{ item.url }}' } }, ], - }, { sortKeys: false })); + }, null, 2)); mockExploreUrl.mockResolvedValue({ site: 'demo', @@ -411,7 +410,7 @@ describe('generateVerifiedFromUrl', () => { expect(result.status).toBe('success'); expect( path.normalize(result.adapter!.path).endsWith( - path.join('verified', 'search.verified.yaml'), + path.join('verified', 'search.verified.ts'), ), ).toBe(true); expect(result.adapter?.path).not.toBe(candidatePath); @@ -428,8 +427,8 @@ describe('generateVerifiedFromUrl', () => { // ── needs-human-check outcomes ──────────────────────────────────────────── it('returns needs-human-check with structured escalation when repair exhausted', async () => { - const candidatePath = path.join(tempDir, 'hot.yaml'); - fs.writeFileSync(candidatePath, yaml.dump({ + const candidatePath = path.join(tempDir, 'hot.json'); + fs.writeFileSync(candidatePath, JSON.stringify({ site: 'demo', name: 'hot', description: 'demo hot', @@ -446,7 +445,7 @@ describe('generateVerifiedFromUrl', () => { { map: { rank: '${{ index + 1 }}', title: '${{ item.title }}', url: '${{ item.url }}' } }, { limit: '${{ args.limit | default(20) }}' }, ], - }, { sortKeys: false })); + }, null, 2)); mockExploreUrl.mockResolvedValue({ site: 'demo', @@ -517,8 +516,8 @@ describe('generateVerifiedFromUrl', () => { }); it('returns needs-human-check with ask-for-sample-arg for unsupported required args', async () => { - const candidatePath = path.join(tempDir, 'detail.yaml'); - fs.writeFileSync(candidatePath, yaml.dump({ + const candidatePath = path.join(tempDir, 'detail.json'); + fs.writeFileSync(candidatePath, JSON.stringify({ site: 'demo', name: 'detail', description: 'demo detail', @@ -533,7 +532,7 @@ describe('generateVerifiedFromUrl', () => { { fetch: { url: 'https://demo.test/api/detail?id=${{ args.id }}' } }, { select: 'data.item' }, ], - }, { sortKeys: false })); + }, null, 2)); mockExploreUrl.mockResolvedValue({ site: 'demo', @@ -731,8 +730,8 @@ describe('generateVerifiedFromUrl', () => { }); it('emits explore + synthesize continue hints with candidate on success path', async () => { - const hotPath = path.join(tempDir, 'hot.yaml'); - fs.writeFileSync(hotPath, yaml.dump({ + const hotPath = path.join(tempDir, 'hot.json'); + fs.writeFileSync(hotPath, JSON.stringify({ site: 'demo', name: 'hot', description: 'demo hot', @@ -745,7 +744,7 @@ describe('generateVerifiedFromUrl', () => { { fetch: { url: 'https://demo.test/api/hot' } }, { select: 'data.items' }, ], - }, { sortKeys: false })); + }, null, 2)); mockExploreUrl.mockResolvedValue({ site: 'demo', @@ -820,8 +819,8 @@ describe('generateVerifiedFromUrl', () => { }); it('emits cascade stop hint when auth-too-complex', async () => { - const hotPath = path.join(tempDir, 'hot.yaml'); - fs.writeFileSync(hotPath, yaml.dump({ + const hotPath = path.join(tempDir, 'hot.json'); + fs.writeFileSync(hotPath, JSON.stringify({ site: 'demo', name: 'hot', description: 'demo hot', @@ -834,7 +833,7 @@ describe('generateVerifiedFromUrl', () => { { fetch: { url: 'https://demo.test/api/hot' } }, { select: 'data.items' }, ], - }, { sortKeys: false })); + }, null, 2)); mockExploreUrl.mockResolvedValue({ site: 'demo', @@ -897,8 +896,8 @@ describe('generateVerifiedFromUrl', () => { }); it('does NOT emit P2 hint for unsupported-required-args (P1-only decision)', async () => { - const detailPath = path.join(tempDir, 'detail.yaml'); - fs.writeFileSync(detailPath, yaml.dump({ + const detailPath = path.join(tempDir, 'detail.json'); + fs.writeFileSync(detailPath, JSON.stringify({ site: 'demo', name: 'detail', description: 'demo detail', @@ -913,7 +912,7 @@ describe('generateVerifiedFromUrl', () => { { fetch: { url: 'https://demo.test/api/detail?id=${{ args.id }}' } }, { select: 'data.item' }, ], - }, { sortKeys: false })); + }, null, 2)); mockExploreUrl.mockResolvedValue({ site: 'demo', diff --git a/src/generate-verified.ts b/src/generate-verified.ts index a8992989..22ac05a9 100644 --- a/src/generate-verified.ts +++ b/src/generate-verified.ts @@ -18,7 +18,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import yaml from 'js-yaml'; import { exploreUrl } from './explore.js'; import { loadExploreBundle, synthesizeFromExplore, type CandidateYaml, type SynthesizeCandidateSummary } from './synthesize.js'; import { normalizeGoal, selectCandidate } from './generate.js'; @@ -252,8 +251,8 @@ function buildStats(args: { }; } -function readCandidateYaml(filePath: string): CandidateYaml { - const loaded = yaml.load(fs.readFileSync(filePath, 'utf-8')) as CandidateYaml | null; +function readCandidateJson(filePath: string): CandidateYaml { + const loaded = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as CandidateYaml | null; if (!loaded || typeof loaded !== 'object') { throw new CommandExecutionError(`Generated candidate is invalid: ${filePath}`); } @@ -477,25 +476,100 @@ async function probeCandidateStrategy(page: IPage, endpointUrl: string): Promise // ── Artifact persistence ────────────────────────────────────────────────────── -async function registerVerifiedAdapter(candidate: CandidateYaml, metadata: VerifiedArtifactMetadata): Promise<{ yamlPath: string; metadataPath: string }> { +function candidateToTs(candidate: CandidateYaml): string { + const strategyMap: Record = { + public: 'Strategy.PUBLIC', + cookie: 'Strategy.COOKIE', + header: 'Strategy.HEADER', + intercept: 'Strategy.INTERCEPT', + ui: 'Strategy.UI', + }; + const stratEnum = strategyMap[candidate.strategy?.toLowerCase()] ?? 'Strategy.COOKIE'; + const browser = detectBrowserFlag(candidate); + + const argsArray = Object.entries(candidate.args ?? {}).map(([name, def]) => { + const parts: string[] = [`name: '${name}'`]; + if (def.type && def.type !== 'str') parts.push(`type: '${def.type}'`); + if (def.required) parts.push('required: true'); + if (def.default !== undefined) parts.push(`default: ${JSON.stringify(def.default)}`); + if (def.description) parts.push(`help: '${def.description.replace(/'/g, "\\'")}'`); + return ` { ${parts.join(', ')} }`; + }); + + const formatStepValue = (v: unknown): string => { + if (typeof v === 'string') { + if (v.includes('\n') || v.includes("'")) { + return '`' + v.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${') + '`'; + } + return `'${v.replace(/\\/g, '\\\\')}'`; + } + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + if (v === null || v === undefined) return 'undefined'; + if (Array.isArray(v)) return `[${v.map(formatStepValue).join(', ')}]`; + if (typeof v === 'object') { + const entries = Object.entries(v as Record); + const items = entries.map(([k, val]) => `${k}: ${formatStepValue(val)}`); + return `{ ${items.join(', ')} }`; + } + return String(v); + }; + + const pipelineSteps = (candidate.pipeline ?? []).map((step) => { + const entries = Object.entries(step as Record); + if (entries.length === 1) { + const [op, value] = entries[0]; + return ` { ${op}: ${formatStepValue(value)} }`; + } + return ` ${formatStepValue(step)}`; + }); + + const lines: string[] = []; + lines.push("import { cli, Strategy } from '@jackwener/opencli/registry';"); + lines.push(''); + lines.push('cli({'); + lines.push(` site: '${candidate.site}',`); + lines.push(` name: '${candidate.name}',`); + if (candidate.description) lines.push(` description: '${candidate.description.replace(/'/g, "\\'")}',`); + if (candidate.domain) lines.push(` domain: '${candidate.domain}',`); + lines.push(` strategy: ${stratEnum},`); + lines.push(` browser: ${browser},`); + if (argsArray.length > 0) { + lines.push(` args: [`); + lines.push(argsArray.join(',\n') + ','); + lines.push(' ],'); + } + if (candidate.columns?.length) { + lines.push(` columns: [${candidate.columns.map(c => `'${c}'`).join(', ')}],`); + } + if (pipelineSteps.length > 0) { + lines.push(' pipeline: ['); + lines.push(pipelineSteps.join(',\n') + ','); + lines.push(' ],'); + } + lines.push('});'); + lines.push(''); + return lines.join('\n'); +} + +async function registerVerifiedAdapter(candidate: CandidateYaml, metadata: VerifiedArtifactMetadata): Promise<{ adapterPath: string; metadataPath: string }> { const siteDir = path.join(USER_CLIS_DIR, candidate.site); - const yamlPath = path.join(siteDir, `${candidate.name}.yaml`); + const adapterPath = path.join(siteDir, `${candidate.name}.ts`); const metadataPath = path.join(siteDir, `${candidate.name}.meta.json`); await fs.promises.mkdir(siteDir, { recursive: true }); - await fs.promises.writeFile(yamlPath, yaml.dump(candidate, { sortKeys: false, lineWidth: 120 })); + await fs.promises.writeFile(adapterPath, candidateToTs(candidate)); await fs.promises.writeFile(metadataPath, JSON.stringify(metadata, null, 2)); - registerCommand(candidateToCommand(candidate, yamlPath)); - return { yamlPath, metadataPath }; + registerCommand(candidateToCommand(candidate, adapterPath)); + return { adapterPath, metadataPath }; } -async function writeVerifiedArtifact(candidate: CandidateYaml, exploreDir: string, metadata: VerifiedArtifactMetadata): Promise<{ yamlPath: string; metadataPath: string }> { +async function writeVerifiedArtifact(candidate: CandidateYaml, exploreDir: string, metadata: VerifiedArtifactMetadata): Promise<{ adapterPath: string; metadataPath: string }> { const outDir = path.join(exploreDir, 'verified'); - const yamlPath = path.join(outDir, `${candidate.name}.verified.yaml`); + const adapterPath = path.join(outDir, `${candidate.name}.verified.ts`); const metadataPath = path.join(outDir, `${candidate.name}.verified.meta.json`); await fs.promises.mkdir(outDir, { recursive: true }); - await fs.promises.writeFile(yamlPath, yaml.dump(candidate, { sortKeys: false, lineWidth: 120 })); + await fs.promises.writeFile(adapterPath, candidateToTs(candidate)); await fs.promises.writeFile(metadataPath, JSON.stringify(metadata, null, 2)); - return { yamlPath, metadataPath }; + return { adapterPath, metadataPath }; } // ── Session error classification ────────────────────────────────────────────── @@ -626,7 +700,7 @@ export async function generateVerifiedFromUrl(opts: GenerateVerifiedOptions): Pr } const expectedFields = Object.keys(context.endpoint.detectedFields ?? {}); - const originalCandidate = readCandidateYaml(selected.path); + const originalCandidate = readCandidateJson(selected.path); const unsupportedArgs = getUnsupportedVerificationArgs(originalCandidate); // ── Escalation: unsupported required args ─────────────────────────────── @@ -721,7 +795,7 @@ export async function generateVerifiedFromUrl(opts: GenerateVerifiedOptions): Pr name: candidate.name, command: commandName(candidate.site, candidate.name), strategy: bestStrategy, - path: artifact.yamlPath, + path: artifact.adapterPath, metadata_path: artifact.metadataPath, reusability: 'verified-artifact', }, @@ -810,7 +884,7 @@ export async function generateVerifiedFromUrl(opts: GenerateVerifiedOptions): Pr name: repaired.name, command: commandName(repaired.site, repaired.name), strategy: bestStrategy, - path: artifact.yamlPath, + path: artifact.adapterPath, metadata_path: artifact.metadataPath, reusability: 'verified-artifact', }, diff --git a/src/synthesize.ts b/src/synthesize.ts index e58636b0..6ce74e1b 100644 --- a/src/synthesize.ts +++ b/src/synthesize.ts @@ -1,11 +1,10 @@ /** * Synthesize candidate CLIs from explore artifacts. - * Generates evaluate-based YAML pipelines (matching hand-written adapter patterns). + * Generates evaluate-based pipelines (matching hand-written adapter patterns). */ import * as fs from 'node:fs'; import * as path from 'node:path'; -import yaml from 'js-yaml'; import { VOLATILE_PARAMS, SEARCH_PARAMS, LIMIT_PARAMS, PAGINATION_PARAMS } from './constants.js'; import type { ExploreAuthSummary, ExploreEndpointArtifact, ExploreManifest } from './explore.js'; @@ -103,8 +102,8 @@ export function synthesizeFromExplore( const endpoint = chooseEndpoint(cap, bundle.endpoints); if (!endpoint) continue; const candidate = buildCandidateYaml(site, bundle.manifest, cap, endpoint); - const filePath = path.join(targetDir, `${candidate.name}.yaml`); - fs.writeFileSync(filePath, yaml.dump(candidate.yaml, { sortKeys: false, lineWidth: 120 })); + const filePath = path.join(targetDir, `${candidate.name}.json`); + fs.writeFileSync(filePath, JSON.stringify(candidate.yaml, null, 2)); candidates.push({ name: candidate.name, path: filePath, strategy: cap.strategy }); } @@ -201,7 +200,7 @@ function buildEvaluateScript(url: string, itemPath: string, endpoint: ExploreEnd ].join('\n'); } -// ── YAML pipeline generation ─────────────────────────────────────────────── +// ── Pipeline generation ──────────────────────────────────────────────────── function buildCandidateYaml(site: string, manifest: ExploreManifestLike, cap: SynthesizeCapability, endpoint: ExploreEndpointArtifact): { name: string; yaml: CandidateYaml } { const needsBrowser = cap.strategy !== 'public'; @@ -233,12 +232,12 @@ function buildCandidateYaml(site: string, manifest: ExploreManifestLike, cap: Sy if (cap.itemPath) tapStep.select = cap.itemPath; pipeline.push({ tap: tapStep }); } else if (needsBrowser) { - // Browser-based: navigate + evaluate (like bilibili/hot.yaml, twitter/trending.yaml) + // Browser-based: navigate + evaluate (like bilibili/hot, twitter/trending) pipeline.push({ navigate: manifest.target_url }); const itemPath = cap.itemPath ?? 'data.data.list'; pipeline.push({ evaluate: buildEvaluateScript(templatedUrl, itemPath, endpoint) }); } else { - // Public API: direct fetch (like hackernews/top.yaml) + // Public API: direct fetch (like hackernews/top) pipeline.push({ fetch: { url: templatedUrl } }); if (cap.itemPath) pipeline.push({ select: cap.itemPath }); } From 07184213956e706fc919d75d1fddaaf56ed98051 Mon Sep 17 00:00:00 2001 From: jackwener Date: Wed, 8 Apr 2026 22:31:20 +0800 Subject: [PATCH 3/5] fix: close YAML migration gaps in plugin scaffold, validation, and scan - plugin-scaffold.ts: generate hello.ts (TS pipeline) instead of hello.yaml - plugin.ts validatePluginStructure: no longer accept .yaml as valid command file - plugin.ts scanPluginCommands: remove .yaml/.yml from scanned extensions - discovery.ts: add explicit log.warn() when YAML files detected in clis/ or plugins/ - plugin.test.ts: update all test fixtures from .yaml to .js - plugin-scaffold.test.ts: update hello.yaml references to hello.ts - Delete dead src/yaml-schema.ts Resolves PR #887 review blockers from @mbp-codex-pr0. --- src/discovery.ts | 8 +++++ src/plugin-scaffold.test.ts | 2 +- src/plugin-scaffold.ts | 45 ++++++++++++++------------- src/plugin.test.ts | 62 ++++++++++++++++++------------------- src/plugin.ts | 8 ++--- src/yaml-schema.ts | 48 ---------------------------- 6 files changed, 67 insertions(+), 106 deletions(-) delete mode 100644 src/yaml-schema.ts diff --git a/src/discovery.ts b/src/discovery.ts index fabe9eca..d72e7cde 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -189,6 +189,10 @@ async function discoverClisFromFs(dir: string): Promise { const files = await fs.promises.readdir(siteDir); await Promise.all(files.map(async (file) => { const filePath = path.join(siteDir, file); + if (file.endsWith('.yaml') || file.endsWith('.yml')) { + log.warn(`Ignoring YAML adapter ${filePath} — YAML format is no longer supported. Convert to TypeScript using cli() from '@jackwener/opencli/registry'.`); + return; + } if ( (file.endsWith('.js') && !file.endsWith('.d.js')) || (file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts')) @@ -227,6 +231,10 @@ async function discoverPluginDir(dir: string, site: string): Promise { const fileSet = new Set(files); await Promise.all(files.map(async (file) => { const filePath = path.join(dir, file); + if (file.endsWith('.yaml') || file.endsWith('.yml')) { + log.warn(`Ignoring YAML plugin ${filePath} — YAML format is no longer supported. Convert to TypeScript using cli() from '@jackwener/opencli/registry'.`); + return; + } if (file.endsWith('.js') && !file.endsWith('.d.js')) { if (!(await isCliModule(filePath))) return; await import(pathToFileURL(filePath).href).catch((err) => { diff --git a/src/plugin-scaffold.test.ts b/src/plugin-scaffold.test.ts index b6fc1013..65a6099a 100644 --- a/src/plugin-scaffold.test.ts +++ b/src/plugin-scaffold.test.ts @@ -27,7 +27,7 @@ describe('createPluginScaffold', () => { expect(result.dir).toBe(dir); expect(result.files).toContain('opencli-plugin.json'); expect(result.files).toContain('package.json'); - expect(result.files).toContain('hello.yaml'); + expect(result.files).toContain('hello.ts'); expect(result.files).toContain('greet.ts'); expect(result.files).toContain('README.md'); diff --git a/src/plugin-scaffold.ts b/src/plugin-scaffold.ts index dcc58403..3802c33d 100644 --- a/src/plugin-scaffold.ts +++ b/src/plugin-scaffold.ts @@ -7,8 +7,8 @@ * / * opencli-plugin.json — manifest with name, version, description * package.json — ESM package with opencli peer dependency - * hello.yaml — sample YAML command - * greet.ts — sample TS command using the current registry API + * hello.ts — sample pipeline command + * greet.ts — sample TS command using func() * README.md — basic documentation */ @@ -76,26 +76,29 @@ export function createPluginScaffold(name: string, opts: ScaffoldOptions = {}): writeFile(targetDir, 'package.json', JSON.stringify(pkg, null, 2) + '\n'); files.push('package.json'); - // hello.yaml — sample YAML command - const yamlContent = `# Sample YAML command for ${name} -# See: https://github.com/jackwener/opencli#yaml-commands - -site: ${name} -name: hello -description: "A sample YAML command" -strategy: public -browser: false + // hello.ts — sample pipeline command + const helloContent = `/** + * Sample pipeline command for ${name}. + * Demonstrates the declarative pipeline API. + */ -domain: https://httpbin.org +import { cli, Strategy } from '@jackwener/opencli/registry'; -pipeline: - - fetch: - url: "https://httpbin.org/get?greeting=hello" - method: GET - - select: "args" +cli({ + site: '${name}', + name: 'hello', + description: 'A sample pipeline command', + strategy: Strategy.PUBLIC, + browser: false, + columns: ['greeting'], + pipeline: [ + { fetch: { url: 'https://httpbin.org/get?greeting=hello' } }, + { select: 'args' }, + ], +}); `; - writeFile(targetDir, 'hello.yaml', yamlContent); - files.push('hello.yaml'); + writeFile(targetDir, 'hello.ts', helloContent); + files.push('hello.ts'); // greet.ts — sample TS command using registry API const tsContent = `/** @@ -140,8 +143,8 @@ opencli plugin install github:/opencli-plugin-${name} | Command | Type | Description | |---------|------|-------------| -| \`${name}/hello\` | YAML | Sample YAML command | -| \`${name}/greet\` | TypeScript | Sample TS command | +| \`${name}/hello\` | Pipeline | Sample pipeline command | +| \`${name}/greet\` | TypeScript | Sample TS command with func() | ## Development diff --git a/src/plugin.test.ts b/src/plugin.test.ts index b1dd456c..74dca56a 100644 --- a/src/plugin.test.ts +++ b/src/plugin.test.ts @@ -197,11 +197,11 @@ describe('validatePluginStructure', () => { expect(res.errors[0]).toContain('No command files found'); }); - it('returns valid for YAML plugin', () => { + it('returns invalid for YAML-only plugin (YAML no longer supported)', () => { fs.writeFileSync(path.join(testDir, 'cmd.yaml'), 'site: test'); const res = _validatePluginStructure(testDir); - expect(res.valid).toBe(true); - expect(res.errors).toHaveLength(0); + expect(res.valid).toBe(false); + expect(res.errors[0]).toContain('No command files found'); }); it('returns valid for JS plugin', () => { @@ -436,7 +436,7 @@ describe('listPlugins', () => { it('lists installed plugins', () => { fs.mkdirSync(testDir, { recursive: true }); - fs.writeFileSync(path.join(testDir, 'hello.yaml'), 'site: test\nname: hello\n'); + fs.writeFileSync(path.join(testDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); const plugins = listPlugins(); const found = plugins.find(p => p.name === '__test-list-plugin__'); @@ -446,7 +446,7 @@ describe('listPlugins', () => { it('includes version metadata from the lock file', () => { fs.mkdirSync(testDir, { recursive: true }); - fs.writeFileSync(path.join(testDir, 'hello.yaml'), 'site: test\nname: hello\n'); + fs.writeFileSync(path.join(testDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); const lock = _readLockFile(); lock['__test-list-plugin__'] = { @@ -476,7 +476,7 @@ describe('listPlugins', () => { const linkPath = path.join(PLUGINS_DIR, '__test-list-plugin__'); fs.mkdirSync(PLUGINS_DIR, { recursive: true }); - fs.writeFileSync(path.join(localTarget, 'hello.yaml'), 'site: test\nname: hello\n'); + fs.writeFileSync(path.join(localTarget, 'hello.js'), 'cli({ site: "test", name: "hello" })'); try { fs.unlinkSync(linkPath); } catch {} try { fs.rmSync(linkPath, { recursive: true, force: true }); } catch {} fs.symlinkSync(localTarget, linkPath, 'dir'); @@ -509,7 +509,7 @@ describe('uninstallPlugin', () => { it('removes plugin directory', () => { fs.mkdirSync(testDir, { recursive: true }); - fs.writeFileSync(path.join(testDir, 'test.yaml'), 'site: test'); + fs.writeFileSync(path.join(testDir, 'test.js'), 'cli({ site: "test", name: "test" })'); uninstallPlugin('__test-uninstall__'); expect(fs.existsSync(testDir)).toBe(false); @@ -517,7 +517,7 @@ describe('uninstallPlugin', () => { it('removes lock entry on uninstall', () => { fs.mkdirSync(testDir, { recursive: true }); - fs.writeFileSync(path.join(testDir, 'test.yaml'), 'site: test'); + fs.writeFileSync(path.join(testDir, 'test.js'), 'cli({ site: "test", name: "test" })'); const lock = _readLockFile(); lock['__test-uninstall__'] = { @@ -546,7 +546,7 @@ describe('updatePlugin', () => { const linkPath = path.join(PLUGINS_DIR, '__test-local-update__'); fs.mkdirSync(PLUGINS_DIR, { recursive: true }); - fs.writeFileSync(path.join(localTarget, 'hello.yaml'), 'site: test\nname: hello\n'); + fs.writeFileSync(path.join(localTarget, 'hello.js'), 'cli({ site: "test", name: "hello" })'); fs.symlinkSync(localTarget, linkPath, 'dir'); const lock = _readLockFile(); @@ -632,7 +632,7 @@ describe('postInstallMonorepoLifecycle', () => { private: true, workspaces: ['packages/*'], })); - fs.writeFileSync(path.join(subDir, 'hello.yaml'), 'site: test\nname: hello\n'); + fs.writeFileSync(path.join(subDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); }); afterEach(() => { @@ -661,9 +661,9 @@ describe('updateAllPlugins', () => { fs.mkdirSync(testDirA, { recursive: true }); fs.mkdirSync(testDirB, { recursive: true }); fs.mkdirSync(testDirC, { recursive: true }); - fs.writeFileSync(path.join(testDirA, 'cmd.yaml'), 'site: a'); - fs.writeFileSync(path.join(testDirB, 'cmd.yaml'), 'site: b'); - fs.writeFileSync(path.join(testDirC, 'cmd.yaml'), 'site: c'); + fs.writeFileSync(path.join(testDirA, 'cmd.js'), 'cli({ site: "a", name: "cmd" })'); + fs.writeFileSync(path.join(testDirB, 'cmd.js'), 'cli({ site: "b", name: "cmd" })'); + fs.writeFileSync(path.join(testDirC, 'cmd.js'), 'cli({ site: "c", name: "cmd" })'); const lock = _readLockFile(); lock['plugin-a'] = { @@ -702,7 +702,7 @@ describe('updateAllPlugins', () => { const cloneUrl = String(args[3]); const cloneDir = String(args[4]); fs.mkdirSync(cloneDir, { recursive: true }); - fs.writeFileSync(path.join(cloneDir, 'cmd.yaml'), 'site: test\nname: hello\n'); + fs.writeFileSync(path.join(cloneDir, 'cmd.js'), 'cli({ site: "test", name: "hello" })'); if (cloneUrl.includes('plugin-b')) { fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ name: 'plugin-b' })); } @@ -807,7 +807,7 @@ describe('monorepo uninstall with symlink', () => { const subDir = path.join(monoDir, 'packages', 'sub'); fs.mkdirSync(subDir, { recursive: true }); - fs.writeFileSync(path.join(subDir, 'cmd.yaml'), 'site: test'); + fs.writeFileSync(path.join(subDir, 'cmd.js'), 'cli({ site: "test", name: "cmd" })'); fs.mkdirSync(PLUGINS_DIR, { recursive: true }); fs.symlinkSync(subDir, pluginDir, 'dir'); @@ -876,7 +876,7 @@ describe('listPlugins with monorepo metadata', () => { beforeEach(() => { fs.mkdirSync(testSymlinkTarget, { recursive: true }); - fs.writeFileSync(path.join(testSymlinkTarget, 'hello.yaml'), 'site: test\nname: hello\n'); + fs.writeFileSync(path.join(testSymlinkTarget, 'hello.js'), 'cli({ site: "test", name: "hello" })'); fs.mkdirSync(PLUGINS_DIR, { recursive: true }); try { fs.unlinkSync(testLink); } catch {} @@ -920,7 +920,7 @@ describe('installLocalPlugin', () => { beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-local-install-')); - fs.writeFileSync(path.join(tmpDir, 'hello.yaml'), 'site: test\nname: hello\n'); + fs.writeFileSync(path.join(tmpDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); }); afterEach(() => { @@ -1120,7 +1120,7 @@ describe('installPlugin transactional staging', () => { if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') { const cloneDir = String(args[args.length - 1]); fs.mkdirSync(cloneDir, { recursive: true }); - fs.writeFileSync(path.join(cloneDir, 'hello.yaml'), 'site: test\nname: hello\n'); + fs.writeFileSync(path.join(cloneDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ name: standaloneName })); return ''; } @@ -1153,7 +1153,7 @@ describe('installPlugin transactional staging', () => { alpha: { path: 'packages/alpha' }, }, })); - fs.writeFileSync(path.join(alphaDir, 'hello.yaml'), 'site: test\nname: hello\n'); + fs.writeFileSync(path.join(alphaDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); return ''; } if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') { @@ -1206,7 +1206,7 @@ describe('installPlugin with existing monorepo', () => { [pluginName]: { path: `packages/${pluginName}` }, }, })); - fs.writeFileSync(path.join(subDir, 'hello.yaml'), 'site: test\nname: hello\n'); + fs.writeFileSync(path.join(subDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); mockExecFileSync.mockImplementation((cmd, args) => { if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') { @@ -1262,7 +1262,7 @@ describe('updatePlugin transactional staging', () => { it('keeps the existing standalone plugin when staged update preparation fails', () => { fs.mkdirSync(standaloneDir, { recursive: true }); - fs.writeFileSync(path.join(standaloneDir, 'old.yaml'), 'site: old\nname: old\n'); + fs.writeFileSync(path.join(standaloneDir, 'old.js'), 'cli({ site: "old", name: "old" })'); const lock = _readLockFile(); lock[standaloneName] = { @@ -1279,7 +1279,7 @@ describe('updatePlugin transactional staging', () => { if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') { const cloneDir = String(args[4]); fs.mkdirSync(cloneDir, { recursive: true }); - fs.writeFileSync(path.join(cloneDir, 'hello.yaml'), 'site: test\nname: hello\n'); + fs.writeFileSync(path.join(cloneDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ name: standaloneName })); return ''; } @@ -1294,14 +1294,14 @@ describe('updatePlugin transactional staging', () => { expect(() => updatePlugin(standaloneName)).toThrow('npm install failed'); expect(fs.existsSync(standaloneDir)).toBe(true); - expect(fs.readFileSync(path.join(standaloneDir, 'old.yaml'), 'utf-8')).toContain('site: old'); + expect(fs.readFileSync(path.join(standaloneDir, 'old.js'), 'utf-8')).toContain('site: "old"'); expect(_readLockFile()[standaloneName]?.commitHash).toBe('oldhasholdhasholdhasholdhasholdhasholdh'); }); it('keeps the existing monorepo repo and link when staged update preparation fails', () => { const subDir = path.join(monorepoRepoDir, 'packages', monorepoPluginName); fs.mkdirSync(subDir, { recursive: true }); - fs.writeFileSync(path.join(subDir, 'old.yaml'), 'site: old\nname: old\n'); + fs.writeFileSync(path.join(subDir, 'old.js'), 'cli({ site: "old", name: "old" })'); fs.mkdirSync(PLUGINS_DIR, { recursive: true }); fs.symlinkSync(subDir, monorepoLink, 'dir'); @@ -1332,7 +1332,7 @@ describe('updatePlugin transactional staging', () => { [monorepoPluginName]: { path: `packages/${monorepoPluginName}` }, }, })); - fs.writeFileSync(path.join(alphaDir, 'hello.yaml'), 'site: test\nname: hello\n'); + fs.writeFileSync(path.join(alphaDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); return ''; } if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') { @@ -1347,14 +1347,14 @@ describe('updatePlugin transactional staging', () => { expect(() => updatePlugin(monorepoPluginName)).toThrow('npm install failed'); expect(fs.existsSync(monorepoRepoDir)).toBe(true); expect(fs.existsSync(monorepoLink)).toBe(true); - expect(fs.readFileSync(path.join(subDir, 'old.yaml'), 'utf-8')).toContain('site: old'); + expect(fs.readFileSync(path.join(subDir, 'old.js'), 'utf-8')).toContain('site: "old"'); expect(_readLockFile()[monorepoPluginName]?.commitHash).toBe('oldmonooldmonooldmonooldmonooldmonoold'); }); it('relinks monorepo plugins when the updated manifest moves their subPath', () => { const oldSubDir = path.join(monorepoRepoDir, 'packages', 'old-alpha'); fs.mkdirSync(oldSubDir, { recursive: true }); - fs.writeFileSync(path.join(oldSubDir, 'old.yaml'), 'site: old\nname: old\n'); + fs.writeFileSync(path.join(oldSubDir, 'old.js'), 'cli({ site: "old", name: "old" })'); fs.mkdirSync(PLUGINS_DIR, { recursive: true }); fs.symlinkSync(oldSubDir, monorepoLink, 'dir'); @@ -1381,7 +1381,7 @@ describe('updatePlugin transactional staging', () => { [monorepoPluginName]: { path: 'packages/moved-alpha' }, }, })); - fs.writeFileSync(path.join(movedDir, 'hello.yaml'), 'site: test\nname: hello\n'); + fs.writeFileSync(path.join(movedDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); return ''; } if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') { @@ -1403,7 +1403,7 @@ describe('updatePlugin transactional staging', () => { it('rolls back the monorepo repo swap when relinking fails', () => { const oldSubDir = path.join(monorepoRepoDir, 'packages', 'old-alpha'); fs.mkdirSync(oldSubDir, { recursive: true }); - fs.writeFileSync(path.join(oldSubDir, 'old.yaml'), 'site: old\nname: old\n'); + fs.writeFileSync(path.join(oldSubDir, 'old.js'), 'cli({ site: "old", name: "old" })'); fs.mkdirSync(monorepoLink, { recursive: true }); fs.writeFileSync(path.join(monorepoLink, 'blocker.txt'), 'not a symlink'); @@ -1430,7 +1430,7 @@ describe('updatePlugin transactional staging', () => { [monorepoPluginName]: { path: 'packages/moved-alpha' }, }, })); - fs.writeFileSync(path.join(movedDir, 'hello.yaml'), 'site: test\nname: hello\n'); + fs.writeFileSync(path.join(movedDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); return ''; } if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') { @@ -1440,7 +1440,7 @@ describe('updatePlugin transactional staging', () => { }); expect(() => updatePlugin(monorepoPluginName)).toThrow('to be a symlink'); - expect(fs.existsSync(path.join(monorepoRepoDir, 'packages', 'old-alpha', 'old.yaml'))).toBe(true); + expect(fs.existsSync(path.join(monorepoRepoDir, 'packages', 'old-alpha', 'old.js'))).toBe(true); expect(fs.existsSync(path.join(monorepoRepoDir, 'packages', 'moved-alpha'))).toBe(false); expect(fs.readFileSync(path.join(monorepoLink, 'blocker.txt'), 'utf-8')).toBe('not a symlink'); expect(_readLockFile()[monorepoPluginName]?.source).toMatchObject({ diff --git a/src/plugin.ts b/src/plugin.ts index 55c8292f..d5a41507 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -529,7 +529,7 @@ export function getCommitHash(dir: string): string | undefined { /** * Validate that a downloaded plugin directory is a structurally valid plugin. - * Checks for at least one command file (.yaml, .yml, .ts, .js) and a valid + * Checks for at least one command file (.ts, .js) and a valid * package.json if it contains .ts files. */ export function validatePluginStructure(pluginDir: string): ValidationResult { @@ -540,12 +540,11 @@ export function validatePluginStructure(pluginDir: string): ValidationResult { } const files = fs.readdirSync(pluginDir); - const hasYaml = files.some(f => f.endsWith('.yaml') || f.endsWith('.yml')); const hasTs = files.some(f => f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.test.ts')); const hasJs = files.some(f => f.endsWith('.js') && !f.endsWith('.d.js')); - if (!hasYaml && !hasTs && !hasJs) { - errors.push('No command files found in plugin directory. A plugin must contain at least one .yaml, .ts, or .js command file.'); + if (!hasTs && !hasJs) { + errors.push('No command files found in plugin directory. A plugin must contain at least one .ts or .js command file.'); } if (hasTs) { @@ -1243,7 +1242,6 @@ function scanPluginCommands(dir: string): string[] { const names = new Set( files .filter(f => - f.endsWith('.yaml') || f.endsWith('.yml') || (f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.test.ts')) || (f.endsWith('.js') && !f.endsWith('.d.js')) ) diff --git a/src/yaml-schema.ts b/src/yaml-schema.ts deleted file mode 100644 index 473ab74f..00000000 --- a/src/yaml-schema.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Shared YAML CLI definition types. - * Used by both discovery.ts (runtime) and build-manifest.ts (build-time). - */ - -export interface YamlArgDefinition { - type?: string; - default?: unknown; - required?: boolean; - positional?: boolean; - description?: string; - help?: string; - choices?: string[]; -} - -export interface YamlCliDefinition { - site?: string; - name?: string; - description?: string; - domain?: string; - strategy?: string; - browser?: boolean; - args?: Record; - columns?: string[]; - pipeline?: Record[]; - timeout?: number; - navigateBefore?: boolean | string; -} - -import type { Arg } from './registry.js'; - -/** Convert YAML args definition to the internal Arg[] format. */ -export function parseYamlArgs(args: Record | undefined): Arg[] { - if (!args || typeof args !== 'object') return []; - const result: Arg[] = []; - for (const [argName, argDef] of Object.entries(args)) { - result.push({ - name: argName, - type: argDef?.type ?? 'str', - default: argDef?.default, - required: argDef?.required ?? false, - positional: argDef?.positional ?? false, - help: argDef?.description ?? argDef?.help ?? '', - choices: argDef?.choices, - }); - } - return result; -} From 1a1b5c097ebb944d7f2f6fda92a7722b98ca5cca Mon Sep 17 00:00:00 2001 From: jackwener Date: Wed, 8 Apr 2026 22:47:49 +0800 Subject: [PATCH 4/5] refactor: complete YAML removal across docs, skills, record, and binance adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code changes: - record.ts: candidate output changed from .yaml (yaml.dump) to .json (JSON.stringify), removed js-yaml import - src/clis/binance: convert all 11 YAML adapters to TypeScript cli() format - binance/commands.test.ts: rewrite to use registry instead of yaml.load - skill-generate.test.ts, diagnostic.test.ts: update mock paths from .yaml to .ts - build-manifest.ts, synthesize.ts: update stale YAML comments Documentation: - README.md: remove .yaml from Dynamic Loader, fix plugin types, fix synthesize comment - README.zh-CN.md: fix synthesize comment - CONTRIBUTING.md: replace YAML Adapter section with Pipeline Adapter (TS), update arg examples - docs/developer/yaml-adapter.md: replaced with deprecation redirect - docs/developer/architecture.md: remove YAML pipeline references - docs/developer/contributing.md: remove YAML adapter section - docs/developer/ai-workflow.md: YAML → TS in synthesize description - docs/guide/getting-started.md: remove .yaml from loader, update engine description - docs/guide/plugins.md: remove YAML plugin option, update plugin types - docs/index.md, docs/comparison.md: remove YAML adapter references - docs/zh/guide/plugins.md: remove .yaml from scan description Skills: - opencli-explorer/SKILL.md: rewrite YAML vs TS decision tree to TS-only - opencli-oneshot/SKILL.md: replace YAML templates with TS cli() templates - opencli-generate/SKILL.md: YAML artifact path → TS artifact path - opencli-usage/SKILL.md, plugins.md: update adapter format references --- CONTRIBUTING.md | 92 ++++--- README.md | 8 +- README.zh-CN.md | 2 +- docs/comparison.md | 7 +- docs/developer/ai-workflow.md | 5 +- docs/developer/architecture.md | 12 +- docs/developer/contributing.md | 43 +--- docs/developer/yaml-adapter.md | 125 +--------- docs/guide/getting-started.md | 4 +- docs/guide/plugins.md | 45 +--- docs/index.md | 4 +- docs/zh/guide/plugins.md | 2 +- skills/opencli-explorer/SKILL.md | 387 +++++++++++++++--------------- skills/opencli-generate/SKILL.md | 2 +- skills/opencli-oneshot/SKILL.md | 86 ++++--- skills/opencli-usage/SKILL.md | 2 +- skills/opencli-usage/plugins.md | 6 +- src/build-manifest.ts | 2 +- src/clis/binance/asks.ts | 21 ++ src/clis/binance/asks.yaml | 32 --- src/clis/binance/commands.test.ts | 16 +- src/clis/binance/depth.ts | 21 ++ src/clis/binance/depth.yaml | 32 --- src/clis/binance/gainers.ts | 22 ++ src/clis/binance/gainers.yaml | 40 --- src/clis/binance/klines.ts | 21 ++ src/clis/binance/klines.yaml | 36 --- src/clis/binance/losers.ts | 22 ++ src/clis/binance/losers.yaml | 39 --- src/clis/binance/pairs.ts | 21 ++ src/clis/binance/pairs.yaml | 30 --- src/clis/binance/price.ts | 18 ++ src/clis/binance/price.yaml | 30 --- src/clis/binance/prices.ts | 19 ++ src/clis/binance/prices.yaml | 25 -- src/clis/binance/ticker.ts | 21 ++ src/clis/binance/ticker.yaml | 45 ---- src/clis/binance/top.ts | 21 ++ src/clis/binance/top.yaml | 42 ---- src/clis/binance/trades.ts | 20 ++ src/clis/binance/trades.yaml | 32 --- src/diagnostic.test.ts | 4 +- src/record.ts | 8 +- src/skill-generate.test.ts | 14 +- src/synthesize.ts | 2 +- 45 files changed, 561 insertions(+), 927 deletions(-) create mode 100644 src/clis/binance/asks.ts delete mode 100644 src/clis/binance/asks.yaml create mode 100644 src/clis/binance/depth.ts delete mode 100644 src/clis/binance/depth.yaml create mode 100644 src/clis/binance/gainers.ts delete mode 100644 src/clis/binance/gainers.yaml create mode 100644 src/clis/binance/klines.ts delete mode 100644 src/clis/binance/klines.yaml create mode 100644 src/clis/binance/losers.ts delete mode 100644 src/clis/binance/losers.yaml create mode 100644 src/clis/binance/pairs.ts delete mode 100644 src/clis/binance/pairs.yaml create mode 100644 src/clis/binance/price.ts delete mode 100644 src/clis/binance/price.yaml create mode 100644 src/clis/binance/prices.ts delete mode 100644 src/clis/binance/prices.yaml create mode 100644 src/clis/binance/ticker.ts delete mode 100644 src/clis/binance/ticker.yaml create mode 100644 src/clis/binance/top.ts delete mode 100644 src/clis/binance/top.yaml create mode 100644 src/clis/binance/trades.ts delete mode 100644 src/clis/binance/trades.yaml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c1bf3883..207ed89e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,49 +26,43 @@ npm link ## Adding a New Site Adapter -This is the most common type of contribution. Start with YAML when possible, and use TypeScript only when you need browser-side logic or multi-step flows. - -### YAML Adapter (Recommended for data-fetching commands) - -Create a file like `clis//.yaml`: - -```yaml -site: mysite -name: trending -description: Trending posts on MySite -domain: www.mysite.com -strategy: public # public | cookie | header -browser: false # true if browser session is needed - -args: - query: - positional: true - type: str - required: true - description: Search keyword - limit: - type: int - default: 20 - description: Number of items - -pipeline: - - fetch: - url: https://api.mysite.com/trending - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - score: ${{ item.score }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [rank, title, score, url] +All adapters use TypeScript. Use the pipeline API for data-fetching commands, and `func()` for complex browser interactions. + +### Pipeline Adapter (Recommended for data-fetching commands) + +Create a file like `clis//.ts`: + +```typescript +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'mysite', + name: 'trending', + description: 'Trending posts on MySite', + domain: 'www.mysite.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'query', positional: true, required: true, help: 'Search keyword' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of items' }, + ], + columns: ['rank', 'title', 'score', 'url'], + pipeline: [ + { fetch: { url: 'https://api.mysite.com/trending' } }, + { map: { + rank: '${{ index + 1 }}', + title: '${{ item.title }}', + score: '${{ item.score }}', + url: '${{ item.url }}', + }}, + { limit: '${{ args.limit }}' }, + ], +}); ``` -See [`hackernews/top.yaml`](clis/hackernews/top.yaml) for a real example. +See [`hackernews/top.ts`](clis/hackernews/top.ts) for a real example. -### TypeScript Adapter (For complex browser interactions) +### func() Adapter (For complex browser interactions) Create a file like `clis//.ts`: @@ -114,7 +108,7 @@ Use `opencli explore ` to discover APIs and see [opencli-explorer skill](./ ### Validate Your Adapter ```bash -# Validate YAML syntax and schema +# Validate adapter opencli validate # Test your command @@ -137,16 +131,12 @@ Use **positional** for the primary, required argument of a command (the "what" Do **not** convert an argument to positional just because it appears first in the file. If the argument is optional, acts like a filter, or selects a mode/configuration, it should usually stay a named option. -YAML example: -```yaml -args: - query: - positional: true # ← primary arg, user types it directly - type: str - required: true - limit: - type: int # ← config arg, user types --limit 10 - default: 20 +Pipeline example: +```typescript +args: [ + { name: 'query', positional: true, required: true, help: 'Search query' }, // ← primary arg + { name: 'limit', type: 'int', default: 20, help: 'Max results' }, // ← config arg +] ``` TS example: diff --git a/README.md b/README.md index 7966823e..57e221b9 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ A CLI tool that turns **any website**, **Electron app**, or **local CLI tool** i - **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies, `browser` controls the browser directly. - **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, etc). Zero setup. - **Self-healing setup** — `opencli doctor` diagnoses and auto-starts the daemon, extension, and live browser connectivity. -- **Dynamic Loader** — Simply drop `.ts` or `.yaml` adapters into the `clis/` folder for auto-registration. +- **Dynamic Loader** — Simply drop `.ts` adapters into the `clis/` folder for auto-registration. - **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times and pay nothing. - **Deterministic** — Same command, same output schema, every time. Pipeable, scriptable, CI-friendly. - **Broad coverage** — 79+ sites across global and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, and more), plus desktop Electron apps via CDP. @@ -251,9 +251,9 @@ opencli plugin uninstall my-tool | Plugin | Type | Description | |--------|------|-------------| -| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending repositories | +| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | TS | GitHub Trending repositories | | [opencli-plugin-hot-digest](https://github.com/ByteYue/opencli-plugin-hot-digest) | TS | Multi-platform trending aggregator | -| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | YAML | 稀土掘金 (Juejin) hot articles | +| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | TS | 稀土掘金 (Juejin) hot articles | | [opencli-plugin-vk](https://github.com/flobo3/opencli-plugin-vk) | TS | VK (VKontakte) wall, feed, and search | See [Plugins Guide](./docs/guide/plugins.md) for creating your own plugin. @@ -266,7 +266,7 @@ See [Plugins Guide](./docs/guide/plugins.md) for creating your own plugin. ```bash opencli explore https://example.com --site mysite # Discover APIs + capabilities -opencli synthesize mysite # Generate YAML adapters +opencli synthesize mysite # Generate TS adapters opencli generate https://example.com --goal "hot" # One-shot: explore → synthesize → register opencli cascade https://api.example.com/data # Auto-probe: PUBLIC → COOKIE → HEADER ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index c4e40986..7cab6ae6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -383,7 +383,7 @@ opencli plugin uninstall my-tool # 卸载 # 1. Deep Explore — 网络拦截 → 响应分析 → 能力推理 → 框架检测 opencli explore https://example.com --site mysite -# 2. Synthesize — 从探索成果物生成 evaluate-based YAML 适配器 +# 2. Synthesize — 从探索成果物生成 evaluate-based TS 适配器 opencli synthesize mysite # 3. Generate — 一键完成:探索 → 合成 → 注册 diff --git a/docs/comparison.md b/docs/comparison.md index 95ec09b1..a6dc1446 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -6,7 +6,7 @@ OpenCLI occupies a specific niche in the browser automation ecosystem. This guid | Tool | Approach | Best for | |------|----------|----------| -| **opencli** | Pre-built adapters (YAML/TS) | Deterministic site commands, broad platform coverage, desktop apps | +| **opencli** | Pre-built TypeScript adapters | Deterministic site commands, broad platform coverage, desktop apps | | **Browser-Use** | LLM-driven browser control | General-purpose AI browser automation | | **Crawl4AI** | Async web crawler | Large-scale data crawling | | **Firecrawl** | Scraping API / self-hosted | Clean markdown extraction, managed or self-hosted infrastructure | @@ -89,11 +89,11 @@ OpenCLI occupies a specific niche in the browser automation ecosystem. This guid - **Speed** — Adapter commands return in seconds, not minutes. - **Broad platform coverage** — 73+ sites spanning global platforms (Reddit, HackerNews, Twitter, YouTube) and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Douban, Weibo) with adapters that understand local anti-bot patterns. - **Desktop app control** — CDP adapters for Cursor, Codex, Notion, ChatGPT, Discord, and more. -- **Easy to extend** — Drop a `.yaml` or `.ts` adapter into the `clis/` folder for auto-registration. Contributing a new site adapter is straightforward. +- **Easy to extend** — Drop a `.ts` adapter into the `clis/` folder for auto-registration. Contributing a new site adapter is straightforward. ### opencli's Limitations -- **Coverage requires adapters** — opencli only works with sites that have pre-built adapters. Adding a new site means writing a YAML or TypeScript adapter. +- **Coverage requires adapters** — opencli only works with sites that have pre-built adapters. Adding a new site means writing a TypeScript adapter. - **Adapter maintenance** — When a website updates its DOM or API, the corresponding adapter may need updating. The community maintains these, but breakage is possible. - **Not general-purpose** — Cannot handle arbitrary websites. For unknown sites, pair opencli with a general browser tool as a fallback. @@ -118,7 +118,6 @@ Recurring? ──yes──▶ Write an opencli adapter, then use opencli ## Further Reading - [Architecture Overview](./developer/architecture.md) -- [Writing a YAML Adapter](./developer/yaml-adapter.md) - [Writing a TypeScript Adapter](./developer/ts-adapter.md) - [Testing Guide](./developer/testing.md) - [AI Workflow](./developer/ai-workflow.md) diff --git a/docs/developer/ai-workflow.md b/docs/developer/ai-workflow.md index b99540a7..4f883062 100644 --- a/docs/developer/ai-workflow.md +++ b/docs/developer/ai-workflow.md @@ -32,7 +32,7 @@ Outputs to `.opencli/explore//`: ### Step 2: Synthesize -Generate YAML adapters from explore artifacts: +Generate TS adapters from explore artifacts: ```bash opencli synthesize mysite @@ -49,8 +49,7 @@ opencli cascade https://api.example.com/data ### Step 4: Validate & Test ```bash -opencli validate # Validate generated YAML -opencli --limit 3 -f json # Test the command +opencli --limit 3 -f json # Test the command ``` ## 5-Tier Authentication Strategy diff --git a/docs/developer/architecture.md b/docs/developer/architecture.md index 52d9b429..c6780561 100644 --- a/docs/developer/architecture.md +++ b/docs/developer/architecture.md @@ -1,6 +1,6 @@ # Architecture -OpenCLI is built on a **Dual-Engine Architecture** that supports both declarative YAML pipelines and programmatic TypeScript adapters. +OpenCLI is built on a **Dual-Engine Architecture** that supports both declarative pipelines and programmatic TypeScript adapters. ## High-Level Architecture @@ -17,7 +17,7 @@ OpenCLI is built on a **Dual-Engine Architecture** that supports both declarativ ├─────────────────────────────────────────────────────┤ │ Adapter Layer │ │ ┌─────────────────┐ ┌──────────────────────────┐ │ -│ │ YAML Pipeline │ │ TypeScript Adapters │ │ +│ │ Pipeline │ │ TypeScript Adapters │ │ │ │ (declarative) │ │ (browser/desktop/AI) │ │ │ └─────────────────┘ └──────────────────────────┘ │ ├─────────────────────────────────────────────────────┤ @@ -35,7 +35,7 @@ OpenCLI is built on a **Dual-Engine Architecture** that supports both declarativ Central command registry. All adapters register their commands via the `cli()` function with metadata: site, name, description, domain, strategy, args, columns. ### Discovery (`src/discovery.ts`) -CLI discovery and manifest loading. Discovers commands from YAML and TypeScript adapter files, parses YAML pipelines, and registers them into the central registry. +CLI discovery and manifest loading. Discovers commands from TypeScript adapter files, parses pipelines, and registers them into the central registry. ### Execution (`src/execution.ts`) Command execution: argument validation, lazy loading of adapter modules, and executing the appropriate handler function. @@ -47,7 +47,7 @@ Bridges the Registry commands to Commander.js subcommands. Handles positional ar Manages connections to Chrome via the Browser Bridge WebSocket daemon. Handles JSON-RPC messaging, tab management, and extension/standalone mode switching. ### Pipeline (`src/pipeline/`) -The YAML pipeline engine. Processes declarative steps: +The pipeline engine. Processes declarative steps: - **fetch** — HTTP requests with cookie/header strategies - **map** — Data transformation with template expressions - **limit** — Result truncation @@ -76,7 +76,7 @@ src/ ├── main.ts # Entry point ├── cli.ts # Commander.js CLI setup + built-in commands ├── commanderAdapter.ts # Registry → Commander bridge -├── discovery.ts # CLI discovery, manifest loading, YAML parsing +├── discovery.ts # CLI discovery, manifest loading ├── execution.ts # Arg validation, command execution ├── registry.ts # Command registry ├── serialization.ts # Command serialization helpers @@ -84,7 +84,7 @@ src/ ├── browser/ # Browser Bridge connection ├── output.ts # Output formatting ├── doctor.ts # Diagnostic tool -├── pipeline/ # YAML pipeline engine +├── pipeline/ # Pipeline engine │ ├── runner.ts │ ├── template.ts │ ├── transform.ts diff --git a/docs/developer/contributing.md b/docs/developer/contributing.md index af521227..ac215758 100644 --- a/docs/developer/contributing.md +++ b/docs/developer/contributing.md @@ -26,7 +26,7 @@ npm link ## Adding a New Site Adapter -This is the most common type of contribution. Start with YAML when possible, and use TypeScript only when you need browser-side logic or multi-step flows. +This is the most common type of contribution. All adapters use TypeScript with the `cli()` API. Before you start: @@ -34,44 +34,7 @@ Before you start: - Normalize expected adapter failures to `CliError` subclasses instead of raw `Error` whenever possible. Prefer `AuthRequiredError`, `EmptyResultError`, `CommandExecutionError`, `TimeoutError`, and `ArgumentError` so the top-level CLI can render better messages and hints. - If you add a new adapter or make a command newly discoverable, update the matching doc page and the user-facing indexes that expose it. -### YAML Adapter (Recommended for data-fetching commands) - -Create a file like `clis//.yaml`: - -::: v-pre -```yaml -site: mysite -name: trending -description: Trending posts on MySite -domain: www.mysite.com -strategy: public # public | cookie | header -browser: false # true if browser session is needed - -args: - limit: - type: int - default: 20 - description: Number of items - -pipeline: - - fetch: - url: https://api.mysite.com/trending - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - score: ${{ item.score }} - url: ${{ item.url }} - - - limit: ${{ args.limit }} - -columns: [rank, title, score, url] -``` -::: - -See [`hackernews/top.yaml`](https://github.com/jackwener/opencli/blob/main/clis/hackernews/top.yaml) for a real example. - -### TypeScript Adapter (For complex browser interactions) +### TypeScript Adapter Create a file like `clis//.ts`: @@ -108,7 +71,6 @@ cli({ ### Validate Your Adapter ```bash -opencli validate # Validate YAML syntax and schema opencli --limit 3 -f json # Test your command opencli -v # Verbose mode for debugging ``` @@ -142,7 +104,6 @@ chore: bump vitest to v4 npx tsc --noEmit # Type check npm test # Core unit tests npm run test:adapter # Focused adapter tests (if adapter logic changed) - opencli validate # YAML validation (if applicable) ``` 4. Commit using conventional commit format 5. Push and open a PR diff --git a/docs/developer/yaml-adapter.md b/docs/developer/yaml-adapter.md index 652ce83b..02d6a2c4 100644 --- a/docs/developer/yaml-adapter.md +++ b/docs/developer/yaml-adapter.md @@ -1,124 +1,5 @@ -# YAML Adapter Guide +# YAML Adapter Guide (Deprecated) -YAML adapters are the recommended way to add new commands when the site offers a straightforward API. They use a declarative pipeline approach — no TypeScript required. +> **YAML adapters are no longer supported.** All adapters now use TypeScript with the `cli()` API from `@jackwener/opencli/registry`. -Use YAML only when the command stays mostly declarative. If you find yourself embedding long JavaScript expressions, many fallbacks, or multi-step browser logic, move the command to a TypeScript adapter instead of growing an opaque template blob. - -## Basic Structure - -::: v-pre -```yaml -site: mysite # Site identifier -name: trending # Command name (opencli mysite trending) -description: ... # Help text -domain: www.mysite.com -strategy: public # public | cookie | header -browser: false # true if browser session is needed - -args: # CLI arguments - limit: - type: int - default: 20 - description: Number of items - -pipeline: # Data processing steps - - fetch: - url: https://api.mysite.com/trending - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - - - limit: ${{ args.limit }} - -columns: [rank, title, score, url] -``` -::: - -For most commands, keep the primary subject positional. Good examples: - -- `opencli mysite search "rust"` -- `opencli mysite topic 123` -- `opencli mysite download "https://example.com/post/1"` - -Prefer named flags only for optional modifiers such as `--limit`, `--sort`, `--lang`, or `--output`. - -## Pipeline Steps - -### `fetch` -Fetch data from a URL. Supports template expressions for dynamic URLs. - -::: v-pre -```yaml -- fetch: - url: https://api.example.com/search?q=${{ args.query }} - headers: - Accept: application/json -``` -::: - -### `map` - -::: v-pre -Transform each item in the result array. Use `${{ item.xxx }}` for field access and `${{ index }}` for position. - -```yaml -- map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - url: https://example.com${{ item.path }} -``` -::: - -### `limit` -Truncate results to N items. - -::: v-pre -```yaml -- limit: ${{ args.limit }} -``` -::: - -### `filter` -Filter items by condition. - -::: v-pre -```yaml -- filter: ${{ item.score > 100 }} -``` -::: - -### `download` -Download media files. - -::: v-pre -```yaml -- download: - url: ${{ item.imageUrl }} - dir: ./downloads - filename: ${{ item.title | sanitize }}.jpg -``` -::: - -## Template Expressions - -::: v-pre -Use `${{ ... }}` for dynamic values: - -| Expression | Description | -|-----------|-------------| -| `${{ args.limit }}` | CLI argument | -| `${{ item.title }}` | Current item field | -| `${{ index }}` | Current index (0-based) | -| `${{ item.x \| sanitize }}` | Pipe filters | -::: - -## Real Example - -See [`clis/hackernews/top.yaml`](https://github.com/jackwener/opencli/blob/main/clis/hackernews/top.yaml). - -## Guardrails - -- Add fallbacks for optional fields in `map` expressions when upstream payloads may be sparse. -- Keep template expressions short and readable. If the expression starts looking like a mini program, switch to TypeScript. -- If you add a new adapter, also add the matching doc page plus index/sidebar entries so `doc-coverage` stays green. +See [Contributing Guide](../../CONTRIBUTING.md) for how to write TypeScript adapters using the pipeline API or `func()`. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index c7cc05de..0ee04014 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -15,8 +15,8 @@ OpenCLI turns **any website** or **Electron app** into a command-line interface - **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser. - **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies. - **Self-healing setup** — `opencli doctor` auto-starts the daemon and diagnoses extension + live browser connectivity. -- **Dynamic Loader** — Simply drop `.ts` or `.yaml` adapters into the `clis/` folder for auto-registration. -- **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections. +- **Dynamic Loader** — Simply drop `.ts` adapters into the `clis/` folder for auto-registration. +- **Dual-Engine Architecture** — Supports both declarative pipeline adapters and robust browser runtime TypeScript injections. ## Quick Start diff --git a/docs/guide/plugins.md b/docs/guide/plugins.md index 5c32fa62..82638374 100644 --- a/docs/guide/plugins.md +++ b/docs/guide/plugins.md @@ -26,7 +26,7 @@ opencli plugin uninstall github-trending ## How Plugins Work -Plugins live in `~/.opencli/plugins//`. Each subdirectory is scanned at startup for `.yaml`, `.ts`, or `.js` command files — the same formats used by built-in adapters. +Plugins live in `~/.opencli/plugins//`. Each subdirectory is scanned at startup for `.ts` or `.js` command files — the same formats used by built-in adapters. ### Supported Source Formats @@ -140,44 +140,7 @@ OpenCLI records installed plugin versions in `~/.opencli/plugins.lock.json`. Eac ## Creating a Plugin -### Option 1: YAML Plugin (Simplest) - -Zero dependencies, no build step. Just create a `.yaml` file: - -``` -my-plugin/ -├── my-command.yaml -└── README.md -``` - -Example `my-command.yaml`: - -```yaml -site: my-plugin -name: my-command -description: My custom command -strategy: public -browser: false - -args: - limit: - type: int - default: 10 - -pipeline: - - fetch: - url: https://api.example.com/data - - map: - title: ${{ item.title }} - score: ${{ item.score }} - - limit: ${{ args.limit }} - -columns: [title, score] -``` - -### Option 2: TypeScript Plugin - -For richer logic (multi-source aggregation, custom transformations, etc.): +### Creating a TypeScript Plugin ``` my-plugin/ @@ -240,9 +203,9 @@ On startup, if both `my-command.ts` and `my-command.js` exist, the `.js` version | Repo | Type | Description | |------|------|-------------| -| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending repositories | +| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | TS | GitHub Trending repositories | | [opencli-plugin-hot-digest](https://github.com/ByteYue/opencli-plugin-hot-digest) | TS | Multi-platform trending aggregator (zhihu, weibo, bilibili, v2ex, stackoverflow, reddit, linux-do) | -| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | YAML | 稀土掘金 (Juejin) hot articles, categories, and article feed | +| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | TS | 稀土掘金 (Juejin) hot articles, categories, and article feed | | [opencli-plugin-rubysec](https://github.com/nullptrKey/opencli-plugin-rubysec) | TS | RubySec advisory archive and advisory article reader | ## Troubleshooting diff --git a/docs/index.md b/docs/index.md index b1791ccd..cad00897 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,11 +25,11 @@ features: details: "explore discovers APIs, synthesize generates adapters, cascade finds auth strategies. Built for AI-first workflows." - icon: ⚡ title: Dual-Engine Architecture - details: Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections for maximum flexibility. + details: Supports both declarative pipeline adapters and robust browser runtime TypeScript injections for maximum flexibility. - icon: 🔧 title: Self-Healing Setup details: "opencli doctor auto-starts the daemon and diagnoses extension + live browser connectivity." - icon: 📦 title: Dynamic Loader - details: Simply drop .ts or .yaml adapters into the clis/ folder for auto-registration. Zero boilerplate. + details: Simply drop .ts adapters into the clis/ folder for auto-registration. Zero boilerplate. --- diff --git a/docs/zh/guide/plugins.md b/docs/zh/guide/plugins.md index 9ea465a9..b7fd0946 100644 --- a/docs/zh/guide/plugins.md +++ b/docs/zh/guide/plugins.md @@ -26,7 +26,7 @@ opencli plugin uninstall github-trending ## 插件目录结构 -Plugins 存放在 `~/.opencli/plugins//`。每个子目录都会在启动时扫描 `.yaml`、`.ts`、`.js` 命令文件,格式与内置 adapters 相同。 +Plugins 存放在 `~/.opencli/plugins//`。每个子目录都会在启动时扫描 `.ts`、`.js` 命令文件,格式与内置 adapters 相同。 ## 安装来源 diff --git a/skills/opencli-explorer/SKILL.md b/skills/opencli-explorer/SKILL.md index 8116d741..fab7de04 100644 --- a/skills/opencli-explorer/SKILL.md +++ b/skills/opencli-explorer/SKILL.md @@ -1,6 +1,6 @@ --- name: opencli-explorer -description: Use when creating a new OpenCLI adapter from scratch, adding support for a new website or platform, or exploring a site's API endpoints via browser DevTools. Covers API discovery workflow, authentication strategy selection, YAML/TS adapter writing, and testing. +description: Use when creating a new OpenCLI adapter from scratch, adding support for a new website or platform, or exploring a site's API endpoints via browser DevTools. Covers API discovery workflow, authentication strategy selection, TS adapter writing, and testing. tags: [opencli, adapter, browser, api-discovery, cli, web-scraping, automation] --- @@ -77,7 +77,7 @@ tags: [opencli, adapter, browser, api-discovery, cli, web-scraping, automation] ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ ┌────────┐ │ 1. 发现 API │ ──▶ │ 2. 选择策略 │ ──▶ │ 3. 写适配器 │ ──▶ │ 4. 测试 │ └─────────────┘ └─────────────┘ └──────────────┘ └────────┘ - explore cascade YAML / TS run + verify + explore cascade TS (cli() API) run + verify ``` --- @@ -123,7 +123,7 @@ opencli bilibili hot -v # 查看已有命令的 pipeline 每步数据流 在开始死磕复杂的抓包拦截之前,按照以下优先级进行尝试: -1. **后缀爆破法 (`.json`)**: 像 Reddit 这样复杂的网站,只要在其 URL 后加上 `.json`(例如 `/r/all.json`),就能在带 Cookie 的情况下直接利用 `fetch` 拿到极其干净的 REST 数据(Tier 2 Cookie 策略极速秒杀)。另外如功能完备的**雪球 (xueqiu)** 也可以走这种纯 API 的方式极简获取,成为你构建简单 YAML 的黄金标杆。 +1. **后缀爆破法 (`.json`)**: 像 Reddit 这样复杂的网站,只要在其 URL 后加上 `.json`(例如 `/r/all.json`),就能在带 Cookie 的情况下直接利用 `fetch` 拿到极其干净的 REST 数据(Tier 2 Cookie 策略极速秒杀)。另外如功能完备的**雪球 (xueqiu)** 也可以走这种纯 API 的方式极简获取,成为你构建简单 TS pipeline 适配器的黄金标杆。 2. **全局状态查找法 (`__INITIAL_STATE__`)**: 许多服务端渲染 (SSR) 的网站(如小红书、Bilibili)会将首页或详情页的完整数据挂载到全局 window 对象上。与其去拦截网络请求,不如直接 `page.evaluate('() => window.__INITIAL_STATE__')` 获取整个数据树。 3. **主动交互触发法 (Active Interaction)**: 很多深层 API(如视频字幕、评论下的回复)是懒加载的。在静态抓包找不到数据时,尝试在 `evaluate` 步骤或手动打断点时,主动去**点击(Click)页面上的对应按钮**(如"CC"、"展开全部"),从而诱发隐藏的 Network Fetch。 4. **框架探测与 Store Action 截断**: 如果站点使用 Vue + Pinia,可以使用 `tap` 步骤调用 action,让前端框架代替你完成复杂的鉴权签名封装。 @@ -223,25 +223,22 @@ cat clis//feed.ts # 读最相似的那个 ## Step 3: 编写适配器 -### YAML vs TS?先看决策树 +### 适配器格式:统一使用 TypeScript + +所有适配器统一使用 TypeScript `cli()` API。YAML adapter 格式已不再支持。 ``` -你的 pipeline 里有 evaluate 步骤(内嵌 JS 代码)? - → ✅ 用 TypeScript (clis//.ts),保存即自动动态注册 - → ❌ 纯声明式(navigate + tap + map + limit)? - → ✅ 用 YAML (clis//.yaml),保存即自动注册 +所有新适配器 → TypeScript (clis//.ts),保存即自动动态注册 ``` -| 场景 | 选择 | 示例 | +| 场景 | 模式 | 示例 | |------|------|------| -| 纯 fetch/select/map/limit | YAML | `v2ex/hot.yaml`, `hackernews/top.yaml` | -| navigate + evaluate(fetch) + map | YAML(评估复杂度) | `zhihu/hot.yaml` | -| navigate + tap + map | YAML ✅ | `xiaohongshu/feed.yaml`, `xiaohongshu/notifications.yaml` | -| 有复杂 JS 逻辑(Pinia state 读取、条件分支) | TS | `xiaohongshu/me.ts`, `bilibili/me.ts` | -| XHR 拦截 + 签名 | TS | `xiaohongshu/search.ts` | -| GraphQL / 分页 / Wbi 签名 | TS | `bilibili/search.ts`, `twitter/search.ts` | - -> **经验法则**:如果你发现 YAML 里嵌了超过 10 行 JS,改用 TS 更可维护。 +| 简单 fetch + 数据映射 | TS `cli()` + `func()` | `v2ex/hot.ts`, `hackernews/top.ts` | +| navigate + evaluate(fetch) + 数据映射 | TS `cli()` + `func()` | `zhihu/hot.ts` | +| Pinia Store Action 触发 | TS `cli()` + `func()` | `xiaohongshu/feed.ts`, `xiaohongshu/notifications.ts` | +| 复杂 JS 逻辑(state 读取、条件分支) | TS `cli()` + `func()` | `xiaohongshu/me.ts`, `bilibili/me.ts` | +| XHR 拦截 + 签名 | TS `cli()` + `func()` | `xiaohongshu/search.ts` | +| GraphQL / 分页 / Wbi 签名 | TS `cli()` + `func()` | `bilibili/search.ts`, `twitter/search.ts` | ### 通用模式:分页 API @@ -264,188 +261,185 @@ func: async (page, kwargs) => { > 大多数站点的 `ps` 上限是 20~50。超过会被静默截断或返回错误。 -### 方式 A: YAML Pipeline(声明式,推荐) +### 方式 A: TS `cli()` + `func()`(标准模式,推荐) -文件路径: `clis//.yaml`,放入即自动注册。 +文件路径: `clis//.ts`,放入即自动注册。 #### Tier 1 — 公开 API 模板 -```yaml -# clis/v2ex/hot.yaml -site: v2ex -name: hot -description: V2EX 热门话题 -domain: www.v2ex.com -strategy: public -browser: false - -args: - limit: - type: int - default: 20 - -pipeline: - - fetch: - url: https://www.v2ex.com/api/topics/hot.json - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - replies: ${{ item.replies }} - - - limit: ${{ args.limit }} +```typescript +// clis/v2ex/hot.ts +import { cli, Strategy } from '@jackwener/opencli/registry'; -columns: [rank, title, replies] +cli({ + site: 'v2ex', + name: 'hot', + description: 'V2EX 热门话题', + domain: 'www.v2ex.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 20 }, + ], + columns: ['rank', 'title', 'replies'], + func: async (_page, kwargs) => { + const res = await fetch('https://www.v2ex.com/api/topics/hot.json'); + const data = await res.json(); + return data.slice(0, kwargs.limit).map((item: any, i: number) => ({ + rank: i + 1, + title: item.title, + replies: item.replies, + })); + }, +}); ``` #### Tier 2 — Cookie 认证模板(最常用) -```yaml -# clis/zhihu/hot.yaml -site: zhihu -name: hot -description: 知乎热榜 -domain: www.zhihu.com - -pipeline: - - navigate: https://www.zhihu.com # 先加载页面建立 session - - - evaluate: | # 在浏览器内发请求,自动带 Cookie - (async () => { - const res = await fetch('/api/v3/feed/topstory/hot-lists/total?limit=50', { - credentials: 'include' - }); - const d = await res.json(); - return (d?.data || []).map(item => { - const t = item.target || {}; - return { - title: t.title, - heat: item.detail_text || '', - answers: t.answer_count, - }; - }); - })() - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - heat: ${{ item.heat }} - answers: ${{ item.answers }} - - - limit: ${{ args.limit }} +```typescript +// clis/zhihu/hot.ts +import { cli, Strategy } from '@jackwener/opencli/registry'; -columns: [rank, title, heat, answers] +cli({ + site: 'zhihu', + name: 'hot', + description: '知乎热榜', + domain: 'www.zhihu.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'limit', type: 'int', default: 50 }, + ], + columns: ['rank', 'title', 'heat', 'answers'], + func: async (page, kwargs) => { + await page.goto('https://www.zhihu.com'); // 先加载页面建立 session + const data = await page.evaluate(`(async () => { + const res = await fetch('/api/v3/feed/topstory/hot-lists/total?limit=50', { + credentials: 'include' + }); + const d = await res.json(); + return (d?.data || []).map(item => { + const t = item.target || {}; + return { + title: t.title, + heat: item.detail_text || '', + answers: t.answer_count, + }; + }); + })()`); + return (data as any[]).slice(0, kwargs.limit).map((item, i) => ({ + rank: i + 1, + title: item.title, + heat: item.heat, + answers: item.answers, + })); + }, +}); ``` -> **关键**: `evaluate` 步骤内的 `fetch` 运行在浏览器页面内,自动携带 `credentials: 'include'`,无需手动处理 Cookie。 +> **关键**: `page.evaluate` 内的 `fetch` 运行在浏览器页面内,自动携带 `credentials: 'include'`,无需手动处理 Cookie。 #### 进阶 — 带搜索参数 -```yaml -# clis/zhihu/search.yaml -site: zhihu -name: search -description: 知乎搜索 - -args: - query: - type: str - required: true - positional: true - description: Search query - limit: - type: int - default: 10 - -pipeline: - - navigate: https://www.zhihu.com - - - evaluate: | - (async () => { - const q = encodeURIComponent('${{ args.query }}'); - const res = await fetch('/api/v4/search_v3?q=' + q + '&t=general&limit=${{ args.limit }}', { - credentials: 'include' - }); - const d = await res.json(); - return (d?.data || []) - .filter(item => item.type === 'search_result') - .map(item => ({ - title: (item.object?.title || '').replace(/<[^>]+>/g, ''), - type: item.object?.type || '', - author: item.object?.author?.name || '', - votes: item.object?.voteup_count || 0, - })); - })() +```typescript +// clis/zhihu/search.ts +import { cli, Strategy } from '@jackwener/opencli/registry'; - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - type: ${{ item.type }} - author: ${{ item.author }} - votes: ${{ item.votes }} +cli({ + site: 'zhihu', + name: 'search', + description: '知乎搜索', + domain: 'www.zhihu.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'query', type: 'str', required: true, positional: true, help: 'Search query' }, + { name: 'limit', type: 'int', default: 10 }, + ], + columns: ['rank', 'title', 'type', 'author', 'votes'], + func: async (page, kwargs) => { + await page.goto('https://www.zhihu.com'); + const data = await page.evaluate(`(async () => { + const q = encodeURIComponent('${kwargs.query}'); + const res = await fetch('/api/v4/search_v3?q=' + q + '&t=general&limit=${kwargs.limit}', { + credentials: 'include' + }); + const d = await res.json(); + return (d?.data || []) + .filter(item => item.type === 'search_result') + .map(item => ({ + title: (item.object?.title || '').replace(/<[^>]+>/g, ''), + type: item.object?.type || '', + author: item.object?.author?.name || '', + votes: item.object?.voteup_count || 0, + })); + })()`); + return (data as any[]).slice(0, kwargs.limit).map((item, i) => ({ + rank: i + 1, + ...item, + })); + }, +}); +``` - - limit: ${{ args.limit }} +#### Tier 4 — Store Action + Interceptor(intercept 策略) -columns: [rank, title, type, author, votes] -``` +适用于 Vue + Pinia/Vuex 的网站(如小红书),使用 `installInterceptor` 捕获 store action 触发的请求: -#### Tier 4 — Store Action Bridge(`tap` 步骤,intercept 策略推荐) +```typescript +// clis/xiaohongshu/notifications.ts +import { cli, Strategy } from '@jackwener/opencli/registry'; -适用于 Vue + Pinia/Vuex 的网站(如小红书),无须手动写 XHR 拦截代码: +cli({ + site: 'xiaohongshu', + name: 'notifications', + description: '小红书通知', + domain: 'www.xiaohongshu.com', + strategy: Strategy.INTERCEPT, + browser: true, + args: [ + { name: 'type', type: 'str', default: 'mentions', help: 'Notification type: mentions, likes, or connections' }, + { name: 'limit', type: 'int', default: 20 }, + ], + columns: ['rank', 'user', 'action', 'content', 'note', 'time'], + func: async (page, kwargs) => { + await page.goto('https://www.xiaohongshu.com/notification'); + await page.wait(3); + + // 安装拦截器捕获包含 '/you/' 的请求 + await page.installInterceptor('/you/'); + + // 通过 Pinia store action 触发 API 请求 + await page.evaluate(`(async () => { + const app = document.querySelector('#app')?.__vue_app__; + const pinia = app?.config?.globalProperties?.$pinia; + const store = pinia?._s?.get('notification'); + if (store?.getNotification) { + await store.getNotification('${kwargs.type}'); + } + })()`); -```yaml -# clis/xiaohongshu/notifications.yaml -site: xiaohongshu -name: notifications -description: "小红书通知" -domain: www.xiaohongshu.com -strategy: intercept -browser: true + // 获取拦截的请求 + const requests = await page.getInterceptedRequests(); + if (!requests?.length) return []; -args: - type: - type: str - default: mentions - description: "Notification type: mentions, likes, or connections" - limit: - type: int - default: 20 + let results: any[] = []; + for (const req of requests) { + const items = req.data?.data?.message_list || []; + results.push(...items); + } -columns: [rank, user, action, content, note, time] + return results.slice(0, kwargs.limit).map((item, i) => ({ + rank: i + 1, + user: item.user_info?.nickname || '', + action: item.title || '', + content: item.comment_info?.content || '', + })); + }, +}); +``` -pipeline: - - navigate: https://www.xiaohongshu.com/notification - - wait: 3 - - tap: - store: notification # Pinia store name - action: getNotification # Store action to call - args: # Action arguments - - ${{ args.type | default('mentions') }} - capture: /you/ # URL pattern to capture response - select: data.message_list # Extract sub-path from response - timeout: 8 - - map: - rank: ${{ index + 1 }} - user: ${{ item.user_info.nickname }} - action: ${{ item.title }} - content: ${{ item.comment_info.content }} - - limit: ${{ args.limit | default(20) }} -``` - -> **`tap` 步骤自动完成**:注入 fetch+XHR 双拦截 → 查找 Pinia/Vuex store → 调用 action → 捕获匹配 URL 的响应 → 清理拦截。 -> 如果 store 或 action 找不到,会返回 `hint` 列出所有可用的 store actions,方便调试。 - -| tap 参数 | 必填 | 说明 | -|---------|------|------| -| `store` | ✅ | Pinia store 名称(如 `feed`, `search`, `notification`) | -| `action` | ✅ | Store action 方法名 | -| `capture` | ✅ | URL 子串匹配(匹配网络请求 URL) | -| `args` | ❌ | 传给 action 的参数数组 | -| `select` | ❌ | 从 captured JSON 中提取的路径(如 `data.items`) | -| `timeout` | ❌ | 等待网络响应的超时秒数(默认 5s) | -| `framework` | ❌ | `pinia` 或 `vuex`(默认自动检测) | - -### 方式 B: TypeScript 适配器(编程式) +### 方式 B: TypeScript 适配器(进阶模式) 适用于需要嵌入 JS 代码读取 Pinia state、XHR 拦截、GraphQL、分页、复杂数据转换等场景。 @@ -547,7 +541,7 @@ cli({ ## Step 4: 测试 -> **构建通过 ≠ 功能正常**。`npm run build` 只验证 TypeScript / YAML 语法,不验证运行时行为。 +> **构建通过 ≠ 功能正常**。`npm run build` 只验证 TypeScript 语法,不验证运行时行为。 > 每个新命令 **必须实际运行** 并确认输出正确后才算完成。 ### 必做清单 @@ -566,7 +560,7 @@ opencli mysite hot --limit 3 -f json # JSON 输出确认字段完整 ### tap 步骤调试(intercept 策略专用) -> **不要猜 store name / action name**。先用 evaluate 探索,再写 YAML。 +> **不要猜 store name / action name**。先用 evaluate 探索,再写 TS 适配器。 #### Step 1: 列出所有 Pinia store @@ -601,8 +595,8 @@ opencli evaluate "(() => { ``` ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────┐ - │ 1. navigate │ ──▶ │ 2. 探索 store │ ──▶ │ 3. 写 YAML │ ──▶ │ 4. 测试 │ - │ 到目标页面 │ │ name/action │ │ tap 步骤 │ │ 运行验证 │ + │ 1. navigate │ ──▶ │ 2. 探索 store │ ──▶ │ 3. 写 TS │ ──▶ │ 4. 测试 │ + │ 到目标页面 │ │ name/action │ │ interceptor │ │ 运行验证 │ └──────────────┘ └──────────────┘ └──────────────┘ └────────┘ ``` @@ -618,20 +612,20 @@ opencli mysite hot -f csv > data.csv # 确认 CSV 可导入 ## Step 5: 提交发布 -文件放入 `clis//` 即自动注册(YAML 或 TS 无需手动 import),然后: +文件放入 `clis//` 即自动注册(TS 文件无需手动 import),然后: ```bash opencli list | grep mysite # 确认注册 git add clis/mysite/ && git commit -m "feat(mysite): add hot" && git push ``` -> **架构理念**:OpenCLI 内建 **Zero-Dependency jq** 数据流 — 所有解析在 `evaluate` 的原生 JS 内完成,外层 YAML 用 `select`/`map` 提取,无需依赖系统 `jq` 二进制。 +> **架构理念**:OpenCLI 内建 **Zero-Dependency jq** 数据流 — 所有解析在 `evaluate` 的原生 JS 内完成,TS `func()` 内直接处理数据,无需依赖系统 `jq` 二进制。 --- ## 进阶模式: 级联请求 (Cascading Requests) -当目标数据需要多步 API 链式获取时(如 `BVID → CID → 字幕列表 → 字幕内容`),必须使用 **TS 适配器**。YAML 无法处理这种多步逻辑。 +当目标数据需要多步 API 链式获取时(如 `BVID → CID → 字幕列表 → 字幕内容`),在 TS `func()` 内按步骤串联即可。 ### 模板代码 @@ -697,7 +691,7 @@ cli({ | 陷阱 | 表现 | 解决方案 | |------|------|---------| -| 缺少 `navigate` | evaluate 报 `Target page context` 错误 | 在 evaluate 前加 `navigate:` 步骤 | +| 缺少 `navigate` | evaluate 报 `Target page context` 错误 | 在 evaluate 前加 `page.goto()` 步骤 | | 嵌套字段访问 | `${{ item.node?.title }}` 不工作 | 在 evaluate 中 flatten 数据,不在模板中用 optional chaining | | 缺少 `strategy: public` | 公开 API 也启动浏览器,7s → 1s | 公开 API 加上 `strategy: public` + `browser: false` | | evaluate 返回字符串 | map 步骤收到 `""` 而非数组 | pipeline 有 auto-parse,但建议在 evaluate 内 `.map()` 整形 | @@ -706,7 +700,7 @@ cli({ | Extension tab 残留 | Chrome 多出 `chrome-extension://` tab | 已自动清理;若残留,手动关闭即可 | | TS evaluate 格式 | `() => {}` 报 `result is not a function` | TS 中 `page.evaluate()` 必须用 IIFE:`(async () => { ... })()` | | 页面异步加载 | evaluate 拿到空数据(store state 还没更新) | 在 evaluate 内用 polling 等待数据出现,或增加 `wait` 时间 | -| YAML 内嵌大段 JS | 调试困难,字符串转义问题 | 超过 10 行 JS 的命令改用 TS adapter | +| evaluate 内嵌大段 JS | 调试困难,字符串转义问题 | 把逻辑放在 `func()` 内,用原生 TS 编写 | | **风控被拦截(伪200)** | 获取到的 JSON 里核心数据是 `""` (空串) | 极易被误判。必须添加断言!无核心数据立刻要求升级鉴权 Tier 并重新配置 Cookie | | **API 没找见** | `explore` 工具打分出来的都拿不到深层数据 | 点击页面按钮诱发懒加载数据,再结合 `getInterceptedRequests` 获取 | @@ -723,11 +717,11 @@ opencli generate https://www.example.com --goal "hot" # 或分步执行: opencli explore https://www.example.com --site mysite # 发现 API opencli explore https://www.example.com --auto --click "字幕,CC" # 模拟点击触发懒加载 API -opencli synthesize mysite # 生成候选 YAML +opencli synthesize mysite # 生成候选 TS opencli verify mysite/hot --smoke # 冒烟测试 ``` -生成的候选 YAML 保存在 `.opencli/explore/mysite/candidates/`,可直接复制到 `clis/mysite/` 并微调。 +生成的候选 TS 保存在 `.opencli/explore/mysite/candidates/`,可直接复制到 `clis/mysite/` 并微调。 ## Record Workflow @@ -741,7 +735,7 @@ opencli record → 向所有 tab 注入 fetch/XHR 拦截器(幂等,可重复注入) → 每 2s 轮询一次:发现新 tab 自动注入,drain 所有 tab 的捕获缓冲区 → 超时(默认 60s)或按 Enter 停止 - → 分析捕获到的 JSON 请求:去重 → 评分 → 生成候选 YAML + → 分析捕获到的 JSON 请求:去重 → 评分 → 生成候选 TS ``` **拦截器特性**: @@ -765,7 +759,7 @@ opencli record "https://example.com/page" --timeout 120000 # 4. 查看结果 cat .opencli/record//captured.json # 原始捕获 -ls .opencli/record//candidates/ # 候选 YAML +ls .opencli/record//candidates/ # 候选 TS ``` ### 页面类型与捕获预期 @@ -780,15 +774,16 @@ ls .opencli/record//candidates/ # 候选 YAML > **注意**:如果页面在导航完成前就发出了大部分请求(服务端渲染 / SSR 注水),拦截器会错过这些请求。 > 解决方案:在页面加载完成后,手动触发能产生新请求的操作(搜索、翻页、切 Tab、展开折叠项等)。 -### 候选 YAML → TS CLI 转换 +### 候选 TS 微调 -生成的候选 YAML 是起点,通常需要转换为 TypeScript(尤其是 tae 等内部系统): +生成的候选 TS 是起点,通常需要微调(尤其是 tae 等内部系统): -**候选 YAML 结构**(自动生成): -```yaml -site: tae -name: getList # 从 URL path 推断的名称 -strategy: cookie +**候选结构**(自动生成,可能需要调整): +```typescript +// 自动生成的候选,需要微调 +// site: tae +// name: getList # 从 URL path 推断的名称 +// strategy: cookie browser: true pipeline: - navigate: https://... @@ -848,6 +843,6 @@ cli({ |------|------|------| | 捕获 0 条请求 | 拦截器注入失败,或页面无 JSON API | 检查 daemon 是否运行:`curl localhost:19825/status` | | 捕获量少(1~3 条) | 页面是只读详情页,首屏数据已在注入前发出 | 手动操作触发更多请求(搜索/翻页),或换用列表页 | -| 候选 YAML 为 0 | 捕获到的 JSON 都没有 array 结构 | 直接看 `captured.json` 手写 TS CLI | +| 候选 TS 为 0 | 捕获到的 JSON 都没有 array 结构 | 直接看 `captured.json` 手写 TS CLI | | 新开的 tab 没有被拦截 | 轮询间隔内 tab 已关闭 | 缩短 `--poll 500` | | 二次运行 record 时数据不连续 | 正常,每次 `record` 启动都是新的 automation window | 无需处理 | diff --git a/skills/opencli-generate/SKILL.md b/skills/opencli-generate/SKILL.md index d7506dcc..7e349fca 100644 --- a/skills/opencli-generate/SKILL.md +++ b/skills/opencli-generate/SKILL.md @@ -54,7 +54,7 @@ interface SkillOutput { // Structured data command?: string; // e.g. "demo/hot" strategy?: string; // "public" | "cookie" - path?: string; // YAML artifact path + path?: string; // TS artifact path // Human-readable summary (agent can relay to user directly) message: string; diff --git a/skills/opencli-oneshot/SKILL.md b/skills/opencli-oneshot/SKILL.md index 161b06ab..09a97a70 100644 --- a/skills/opencli-oneshot/SKILL.md +++ b/skills/opencli-oneshot/SKILL.md @@ -1,7 +1,7 @@ --- name: opencli-oneshot -description: Use when quickly generating a single OpenCLI command from a specific URL and goal description. 4-step process — open page, capture API, write YAML adapter, test. For full site exploration, use opencli-explorer instead. -tags: [opencli, adapter, quick-start, yaml, cli, one-shot, automation] +description: Use when quickly generating a single OpenCLI command from a specific URL and goal description. 4-step process — open page, capture API, write TS adapter, test. For full site exploration, use opencli-explorer instead. +tags: [opencli, adapter, quick-start, ts, cli, one-shot, automation] --- # CLI-ONESHOT — 单点快速 CLI 生成 @@ -60,8 +60,8 @@ fetch('/api/endpoint', { }).then(r => r.json()) ``` -如果 fetch 能拿到数据 → 用 YAML 或简单 TS adapter。 -如果 fetch 拿不到(签名/风控)→ 用 intercept 策略。 +如果 fetch 能拿到数据 → 用 TS adapter(`cli()` pipeline 或 `func()`)。 +如果 fetch 拿不到(签名/风控)→ 用 intercept 策略(TS `func()` + `installInterceptor`)。 ### Step 4: 套模板,生成 adapter @@ -72,52 +72,50 @@ fetch('/api/endpoint', { ## 认证速查 ``` -fetch(url) 直接能拿到? → Tier 1: public (YAML, browser: false) -fetch(url, {credentials:'include'})? → Tier 2: cookie (YAML) -加 Bearer/CSRF header 后拿到? → Tier 3: header (TS) -都不行,但页面自己能请求成功? → Tier 4: intercept (TS, installInterceptor) +fetch(url) 直接能拿到? → Tier 1: public (TS pipeline, browser: false) +fetch(url, {credentials:'include'})? → Tier 2: cookie (TS pipeline 或 func()) +加 Bearer/CSRF header 后拿到? → Tier 3: header (TS func()) +都不行,但页面自己能请求成功? → Tier 4: intercept (TS func(), installInterceptor) ``` --- ## 模板 -### YAML — Cookie/Public(最简) - -```yaml -# clis//.yaml -site: mysite -name: mycommand -description: "一句话描述" -domain: www.example.com -strategy: cookie # 或 public (加 browser: false) - -args: - limit: - type: int - default: 20 - -pipeline: - - navigate: https://www.example.com/target-page - - - evaluate: | - (async () => { - const res = await fetch('/api/target', { credentials: 'include' }); - const d = await res.json(); - return (d.data?.items || []).map(item => ({ - title: item.title, - value: item.value, - })); - })() - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - value: ${{ item.value }} - - - limit: ${{ args.limit }} - -columns: [rank, title, value] +### TS — Cookie/Public(最简,`func()` 模式) + +```typescript +// clis//.ts +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'mysite', + name: 'mycommand', + description: '一句话描述', + domain: 'www.example.com', + strategy: Strategy.COOKIE, // 或 Strategy.PUBLIC (加 browser: false) + browser: true, + args: [ + { name: 'limit', type: 'int', default: 20 }, + ], + columns: ['rank', 'title', 'value'], + func: async (page, kwargs) => { + await page.goto('https://www.example.com/target-page'); + const data = await page.evaluate(`(async () => { + const res = await fetch('/api/target', { credentials: 'include' }); + const d = await res.json(); + return (d.data?.items || []).map(item => ({ + title: item.title, + value: item.value, + })); + })()`); + return (data as any[]).slice(0, kwargs.limit).map((item, i) => ({ + rank: i + 1, + title: item.title || '', + value: item.value || '', + })); + }, +}); ``` ### TS — Intercept(抓包模式) diff --git a/skills/opencli-usage/SKILL.md b/skills/opencli-usage/SKILL.md index 7896bd1f..9882e927 100644 --- a/skills/opencli-usage/SKILL.md +++ b/skills/opencli-usage/SKILL.md @@ -163,6 +163,6 @@ If a command fails due to a site change (selector, API, or response schema), **a ## Related Skills - **opencli-browser** — Browser automation for AI agents (navigate, click, type, extract via Chrome) -- **opencli-explorer** — Full guide for creating new adapters (API discovery, auth strategy, YAML/TS writing) +- **opencli-explorer** — Full guide for creating new adapters (API discovery, auth strategy, TS writing) - **opencli-oneshot** — Quick 4-step template for adding a single command from a URL - **opencli-autofix** — Automatically fix broken adapters when commands fail diff --git a/skills/opencli-usage/plugins.md b/skills/opencli-usage/plugins.md index cd55743e..4de654f9 100644 --- a/skills/opencli-usage/plugins.md +++ b/skills/opencli-usage/plugins.md @@ -19,13 +19,13 @@ opencli doctor # Diagnose browser bridge (auto-starts daemon, inclu # Deep Explore: network intercept → response analysis → capability inference opencli explore --site -# Synthesize: generate evaluate-based YAML pipelines from explore artifacts +# Synthesize: generate TS adapters from explore artifacts opencli synthesize # Generate: one-shot explore → synthesize → register opencli generate --goal "hot" -# Record: YOU drive the page, opencli captures every API call → YAML candidates +# Record: YOU drive the page, opencli captures every API call → TS candidates opencli record # 录制,site name 从域名推断 opencli record --site mysite # 指定 site name opencli record --timeout 120000 # 自定义超时(毫秒,默认 60000) @@ -77,6 +77,6 @@ opencli bilibili hot -v # Show each pipeline step and data flow |-------|----------| | `npx not found` | Install Node.js: `brew install node` | | `Extension not connected` | 1) Chrome must be open 2) Install opencli Browser Bridge extension | -| `Target page context` error | Add `navigate:` step before `evaluate:` in YAML | +| `Target page context` error | Add `page.goto()` before `page.evaluate()` in TS adapter | | Empty table data | Check if evaluate returns correct data path | | Daemon issues | `curl localhost:19825/status` to check, `curl localhost:19825/logs` for extension logs | diff --git a/src/build-manifest.ts b/src/build-manifest.ts index 02496a1e..435502d6 100644 --- a/src/build-manifest.ts +++ b/src/build-manifest.ts @@ -46,7 +46,7 @@ export interface ManifestEntry { type: 'ts'; /** Relative path from clis/ dir, e.g. 'bilibili/search.js' */ modulePath?: string; - /** Relative path to the original source file from clis/ dir (for YAML: 'site/cmd.yaml') */ + /** Relative path to the original source file from clis/ dir (e.g. 'site/cmd.ts') */ sourceFile?: string; /** Pre-navigation control — see CliCommand.navigateBefore */ navigateBefore?: boolean | string; diff --git a/src/clis/binance/asks.ts b/src/clis/binance/asks.ts new file mode 100644 index 00000000..668a6698 --- /dev/null +++ b/src/clis/binance/asks.ts @@ -0,0 +1,21 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'binance', + name: 'asks', + description: 'Order book ask prices for a trading pair', + domain: 'data-api.binance.vision', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'symbol', type: 'str', required: true, positional: true, help: 'Trading pair symbol (e.g. BTCUSDT, ETHUSDT)' }, + { name: 'limit', type: 'int', default: 10, help: 'Number of price levels (5, 10, 20, 50, 100)' }, + ], + columns: ['rank', 'ask_price', 'ask_qty'], + pipeline: [ + { fetch: { url: 'https://data-api.binance.vision/api/v3/depth?symbol=${{ args.symbol }}&limit=${{ args.limit }}' } }, + { select: 'asks' }, + { map: { rank: '${{ index + 1 }}', ask_price: '${{ item.0 }}', ask_qty: '${{ item.1 }}' } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/src/clis/binance/asks.yaml b/src/clis/binance/asks.yaml deleted file mode 100644 index cc876cde..00000000 --- a/src/clis/binance/asks.yaml +++ /dev/null @@ -1,32 +0,0 @@ -site: binance -name: asks -description: Order book ask prices for a trading pair -domain: data-api.binance.vision -strategy: public -browser: false - -args: - symbol: - type: str - required: true - positional: true - description: "Trading pair symbol (e.g. BTCUSDT, ETHUSDT)" - limit: - type: int - default: 10 - description: Number of price levels (5, 10, 20, 50, 100) - -pipeline: - - fetch: - url: https://data-api.binance.vision/api/v3/depth?symbol=${{ args.symbol }}&limit=${{ args.limit }} - - - select: asks - - - map: - rank: ${{ index + 1 }} - ask_price: ${{ item.0 }} - ask_qty: ${{ item.1 }} - - - limit: ${{ args.limit }} - -columns: [rank, ask_price, ask_qty] diff --git a/src/clis/binance/commands.test.ts b/src/clis/binance/commands.test.ts index 92b6b667..a5ca6df3 100644 --- a/src/clis/binance/commands.test.ts +++ b/src/clis/binance/commands.test.ts @@ -1,14 +1,18 @@ -import fs from 'node:fs'; -import yaml from 'js-yaml'; +import { getRegistry } from '@jackwener/opencli/registry'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { executePipeline } from '../../pipeline/index.js'; +// Import all binance adapters to register them +import './top.js'; +import './gainers.js'; +import './pairs.js'; + type BinanceRow = Record; function loadPipeline(name: string): any[] { - const file = new URL(`./${name}.yaml`, import.meta.url); - const def = yaml.load(fs.readFileSync(file, 'utf-8')) as { pipeline: any[] }; - return def.pipeline; + const cmd = getRegistry().get(`binance/${name}`); + if (!cmd?.pipeline) throw new Error(`Command binance/${name} not found or has no pipeline`); + return cmd.pipeline; } function mockJsonOnce(payload: unknown) { @@ -25,7 +29,7 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe('binance YAML adapters', () => { +describe('binance adapters', () => { it('sorts top pairs by numeric quote volume', async () => { mockJsonOnce([ { symbol: 'SMALL', lastPrice: '1', priceChangePercent: '1.2', highPrice: '1', lowPrice: '1', quoteVolume: '9.9' }, diff --git a/src/clis/binance/depth.ts b/src/clis/binance/depth.ts new file mode 100644 index 00000000..e9d6fe45 --- /dev/null +++ b/src/clis/binance/depth.ts @@ -0,0 +1,21 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'binance', + name: 'depth', + description: 'Order book bid prices for a trading pair', + domain: 'data-api.binance.vision', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'symbol', type: 'str', required: true, positional: true, help: 'Trading pair symbol (e.g. BTCUSDT, ETHUSDT)' }, + { name: 'limit', type: 'int', default: 10, help: 'Number of price levels (5, 10, 20, 50, 100)' }, + ], + columns: ['rank', 'bid_price', 'bid_qty'], + pipeline: [ + { fetch: { url: 'https://data-api.binance.vision/api/v3/depth?symbol=${{ args.symbol }}&limit=${{ args.limit }}' } }, + { select: 'bids' }, + { map: { rank: '${{ index + 1 }}', bid_price: '${{ item.0 }}', bid_qty: '${{ item.1 }}' } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/src/clis/binance/depth.yaml b/src/clis/binance/depth.yaml deleted file mode 100644 index 58ddc684..00000000 --- a/src/clis/binance/depth.yaml +++ /dev/null @@ -1,32 +0,0 @@ -site: binance -name: depth -description: Order book bid prices for a trading pair -domain: data-api.binance.vision -strategy: public -browser: false - -args: - symbol: - type: str - required: true - positional: true - description: "Trading pair symbol (e.g. BTCUSDT, ETHUSDT)" - limit: - type: int - default: 10 - description: Number of price levels (5, 10, 20, 50, 100) - -pipeline: - - fetch: - url: https://data-api.binance.vision/api/v3/depth?symbol=${{ args.symbol }}&limit=${{ args.limit }} - - - select: bids - - - map: - rank: ${{ index + 1 }} - bid_price: ${{ item.0 }} - bid_qty: ${{ item.1 }} - - - limit: ${{ args.limit }} - -columns: [rank, bid_price, bid_qty] diff --git a/src/clis/binance/gainers.ts b/src/clis/binance/gainers.ts new file mode 100644 index 00000000..c561a113 --- /dev/null +++ b/src/clis/binance/gainers.ts @@ -0,0 +1,22 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'binance', + name: 'gainers', + description: 'Top gaining trading pairs by 24h price change', + domain: 'data-api.binance.vision', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 10, help: 'Number of trading pairs' }, + ], + columns: ['rank', 'symbol', 'price', 'change_24h', 'volume'], + pipeline: [ + { fetch: { url: 'https://data-api.binance.vision/api/v3/ticker/24hr' } }, + { filter: 'item.priceChangePercent' }, + { map: { symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_24h: '${{ item.priceChangePercent }}', volume: '${{ item.quoteVolume }}', sort_change: '${{ Number(item.priceChangePercent) }}' } }, + { sort: { by: 'sort_change', order: 'desc' } }, + { map: { rank: '${{ index + 1 }}', symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_24h: '${{ item.priceChangePercent }}', volume: '${{ item.quoteVolume }}' } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/src/clis/binance/gainers.yaml b/src/clis/binance/gainers.yaml deleted file mode 100644 index 36e5732d..00000000 --- a/src/clis/binance/gainers.yaml +++ /dev/null @@ -1,40 +0,0 @@ -site: binance -name: gainers -description: Top gaining trading pairs by 24h price change -domain: data-api.binance.vision -strategy: public -browser: false - -args: - limit: - type: int - default: 10 - description: Number of trading pairs - -pipeline: - - fetch: - url: https://data-api.binance.vision/api/v3/ticker/24hr - - - filter: item.priceChangePercent - - - map: - symbol: ${{ item.symbol }} - price: ${{ item.lastPrice }} - change_24h: ${{ item.priceChangePercent }} - volume: ${{ item.quoteVolume }} - sort_change: ${{ Number(item.priceChangePercent) }} - - - sort: - by: sort_change - order: desc - - - map: - rank: ${{ index + 1 }} - symbol: ${{ item.symbol }} - price: ${{ item.lastPrice }} - change_24h: ${{ item.priceChangePercent }} - volume: ${{ item.quoteVolume }} - - - limit: ${{ args.limit }} - -columns: [rank, symbol, price, change_24h, volume] diff --git a/src/clis/binance/klines.ts b/src/clis/binance/klines.ts new file mode 100644 index 00000000..3f58414f --- /dev/null +++ b/src/clis/binance/klines.ts @@ -0,0 +1,21 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'binance', + name: 'klines', + description: 'Candlestick/kline data for a trading pair', + domain: 'data-api.binance.vision', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'symbol', type: 'str', required: true, positional: true, help: 'Trading pair symbol (e.g. BTCUSDT, ETHUSDT)' }, + { name: 'interval', type: 'str', default: '1d', help: 'Kline interval (1m, 5m, 15m, 1h, 4h, 1d, 1w, 1M)' }, + { name: 'limit', type: 'int', default: 10, help: 'Number of klines (max 1000)' }, + ], + columns: ['open', 'high', 'low', 'close', 'volume'], + pipeline: [ + { fetch: { url: 'https://data-api.binance.vision/api/v3/klines?symbol=${{ args.symbol }}&interval=${{ args.interval }}&limit=${{ args.limit }}' } }, + { map: { open: '${{ item.1 }}', high: '${{ item.2 }}', low: '${{ item.3 }}', close: '${{ item.4 }}', volume: '${{ item.5 }}' } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/src/clis/binance/klines.yaml b/src/clis/binance/klines.yaml deleted file mode 100644 index f2cf3cb7..00000000 --- a/src/clis/binance/klines.yaml +++ /dev/null @@ -1,36 +0,0 @@ -site: binance -name: klines -description: Candlestick/kline data for a trading pair -domain: data-api.binance.vision -strategy: public -browser: false - -args: - symbol: - type: str - required: true - positional: true - description: "Trading pair symbol (e.g. BTCUSDT, ETHUSDT)" - interval: - type: str - default: 1d - description: "Kline interval (1m, 5m, 15m, 1h, 4h, 1d, 1w, 1M)" - limit: - type: int - default: 10 - description: Number of klines (max 1000) - -pipeline: - - fetch: - url: https://data-api.binance.vision/api/v3/klines?symbol=${{ args.symbol }}&interval=${{ args.interval }}&limit=${{ args.limit }} - - - map: - open: ${{ item.1 }} - high: ${{ item.2 }} - low: ${{ item.3 }} - close: ${{ item.4 }} - volume: ${{ item.5 }} - - - limit: ${{ args.limit }} - -columns: [open, high, low, close, volume] diff --git a/src/clis/binance/losers.ts b/src/clis/binance/losers.ts new file mode 100644 index 00000000..c6953e8a --- /dev/null +++ b/src/clis/binance/losers.ts @@ -0,0 +1,22 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'binance', + name: 'losers', + description: 'Top losing trading pairs by 24h price change', + domain: 'data-api.binance.vision', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 10, help: 'Number of trading pairs' }, + ], + columns: ['rank', 'symbol', 'price', 'change_24h', 'volume'], + pipeline: [ + { fetch: { url: 'https://data-api.binance.vision/api/v3/ticker/24hr' } }, + { filter: 'item.priceChangePercent' }, + { map: { symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_24h: '${{ item.priceChangePercent }}', volume: '${{ item.quoteVolume }}', sort_change: '${{ Number(item.priceChangePercent) }}' } }, + { sort: { by: 'sort_change' } }, + { map: { rank: '${{ index + 1 }}', symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_24h: '${{ item.priceChangePercent }}', volume: '${{ item.quoteVolume }}' } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/src/clis/binance/losers.yaml b/src/clis/binance/losers.yaml deleted file mode 100644 index 63c36f1d..00000000 --- a/src/clis/binance/losers.yaml +++ /dev/null @@ -1,39 +0,0 @@ -site: binance -name: losers -description: Top losing trading pairs by 24h price change -domain: data-api.binance.vision -strategy: public -browser: false - -args: - limit: - type: int - default: 10 - description: Number of trading pairs - -pipeline: - - fetch: - url: https://data-api.binance.vision/api/v3/ticker/24hr - - - filter: item.priceChangePercent - - - map: - symbol: ${{ item.symbol }} - price: ${{ item.lastPrice }} - change_24h: ${{ item.priceChangePercent }} - volume: ${{ item.quoteVolume }} - sort_change: ${{ Number(item.priceChangePercent) }} - - - sort: - by: sort_change - - - map: - rank: ${{ index + 1 }} - symbol: ${{ item.symbol }} - price: ${{ item.lastPrice }} - change_24h: ${{ item.priceChangePercent }} - volume: ${{ item.quoteVolume }} - - - limit: ${{ args.limit }} - -columns: [rank, symbol, price, change_24h, volume] diff --git a/src/clis/binance/pairs.ts b/src/clis/binance/pairs.ts new file mode 100644 index 00000000..39ebe075 --- /dev/null +++ b/src/clis/binance/pairs.ts @@ -0,0 +1,21 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'binance', + name: 'pairs', + description: 'List active trading pairs on Binance', + domain: 'data-api.binance.vision', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of trading pairs' }, + ], + columns: ['symbol', 'base', 'quote', 'status'], + pipeline: [ + { fetch: { url: 'https://data-api.binance.vision/api/v3/exchangeInfo' } }, + { select: 'symbols' }, + { filter: 'item.status === \'TRADING\'' }, + { map: { symbol: '${{ item.symbol }}', base: '${{ item.baseAsset }}', quote: '${{ item.quoteAsset }}', status: '${{ item.status }}' } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/src/clis/binance/pairs.yaml b/src/clis/binance/pairs.yaml deleted file mode 100644 index dec32c84..00000000 --- a/src/clis/binance/pairs.yaml +++ /dev/null @@ -1,30 +0,0 @@ -site: binance -name: pairs -description: List active trading pairs on Binance -domain: data-api.binance.vision -strategy: public -browser: false - -args: - limit: - type: int - default: 20 - description: Number of trading pairs - -pipeline: - - fetch: - url: https://data-api.binance.vision/api/v3/exchangeInfo - - - select: symbols - - - filter: item.status === 'TRADING' - - - map: - symbol: ${{ item.symbol }} - base: ${{ item.baseAsset }} - quote: ${{ item.quoteAsset }} - status: ${{ item.status }} - - - limit: ${{ args.limit }} - -columns: [symbol, base, quote, status] diff --git a/src/clis/binance/price.ts b/src/clis/binance/price.ts new file mode 100644 index 00000000..7a6bf894 --- /dev/null +++ b/src/clis/binance/price.ts @@ -0,0 +1,18 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'binance', + name: 'price', + description: 'Quick price check for a trading pair', + domain: 'data-api.binance.vision', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'symbol', type: 'str', required: true, positional: true, help: 'Trading pair symbol (e.g. BTCUSDT, ETHUSDT)' }, + ], + columns: ['symbol', 'price', 'change', 'change_pct', 'high', 'low', 'volume', 'quote_volume', 'trades'], + pipeline: [ + { fetch: { url: 'https://data-api.binance.vision/api/v3/ticker/24hr?symbol=${{ args.symbol }}' } }, + { map: { symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change: '${{ item.priceChange }}', change_pct: '${{ item.priceChangePercent }}', high: '${{ item.highPrice }}', low: '${{ item.lowPrice }}', volume: '${{ item.volume }}', quote_volume: '${{ item.quoteVolume }}', trades: '${{ item.count }}' } }, + ], +}); diff --git a/src/clis/binance/price.yaml b/src/clis/binance/price.yaml deleted file mode 100644 index dd597bab..00000000 --- a/src/clis/binance/price.yaml +++ /dev/null @@ -1,30 +0,0 @@ -site: binance -name: price -description: Quick price check for a trading pair -domain: data-api.binance.vision -strategy: public -browser: false - -args: - symbol: - type: str - required: true - positional: true - description: "Trading pair symbol (e.g. BTCUSDT, ETHUSDT)" - -pipeline: - - fetch: - url: https://data-api.binance.vision/api/v3/ticker/24hr?symbol=${{ args.symbol }} - - - map: - symbol: ${{ item.symbol }} - price: ${{ item.lastPrice }} - change: ${{ item.priceChange }} - change_pct: ${{ item.priceChangePercent }} - high: ${{ item.highPrice }} - low: ${{ item.lowPrice }} - volume: ${{ item.volume }} - quote_volume: ${{ item.quoteVolume }} - trades: ${{ item.count }} - -columns: [symbol, price, change, change_pct, high, low, volume, quote_volume, trades] diff --git a/src/clis/binance/prices.ts b/src/clis/binance/prices.ts new file mode 100644 index 00000000..339c8790 --- /dev/null +++ b/src/clis/binance/prices.ts @@ -0,0 +1,19 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'binance', + name: 'prices', + description: 'Latest prices for all trading pairs', + domain: 'data-api.binance.vision', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of prices' }, + ], + columns: ['rank', 'symbol', 'price'], + pipeline: [ + { fetch: { url: 'https://data-api.binance.vision/api/v3/ticker/price' } }, + { map: { rank: '${{ index + 1 }}', symbol: '${{ item.symbol }}', price: '${{ item.price }}' } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/src/clis/binance/prices.yaml b/src/clis/binance/prices.yaml deleted file mode 100644 index e4553dcb..00000000 --- a/src/clis/binance/prices.yaml +++ /dev/null @@ -1,25 +0,0 @@ -site: binance -name: prices -description: Latest prices for all trading pairs -domain: data-api.binance.vision -strategy: public -browser: false - -args: - limit: - type: int - default: 20 - description: Number of prices - -pipeline: - - fetch: - url: https://data-api.binance.vision/api/v3/ticker/price - - - map: - rank: ${{ index + 1 }} - symbol: ${{ item.symbol }} - price: ${{ item.price }} - - - limit: ${{ args.limit }} - -columns: [rank, symbol, price] diff --git a/src/clis/binance/ticker.ts b/src/clis/binance/ticker.ts new file mode 100644 index 00000000..4ca8b176 --- /dev/null +++ b/src/clis/binance/ticker.ts @@ -0,0 +1,21 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'binance', + name: 'ticker', + description: '24h ticker statistics for top trading pairs by volume', + domain: 'data-api.binance.vision', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of tickers' }, + ], + columns: ['symbol', 'price', 'change_pct', 'high', 'low', 'volume', 'quote_vol', 'trades'], + pipeline: [ + { fetch: { url: 'https://data-api.binance.vision/api/v3/ticker/24hr' } }, + { map: { symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_pct: '${{ item.priceChangePercent }}', high: '${{ item.highPrice }}', low: '${{ item.lowPrice }}', volume: '${{ item.volume }}', quote_vol: '${{ item.quoteVolume }}', trades: '${{ item.count }}', sort_volume: '${{ Number(item.quoteVolume) }}' } }, + { sort: { by: 'sort_volume', order: 'desc' } }, + { map: { symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_pct: '${{ item.priceChangePercent }}', high: '${{ item.highPrice }}', low: '${{ item.lowPrice }}', volume: '${{ item.volume }}', quote_vol: '${{ item.quoteVolume }}', trades: '${{ item.count }}' } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/src/clis/binance/ticker.yaml b/src/clis/binance/ticker.yaml deleted file mode 100644 index 9f2a4645..00000000 --- a/src/clis/binance/ticker.yaml +++ /dev/null @@ -1,45 +0,0 @@ -site: binance -name: ticker -description: 24h ticker statistics for top trading pairs by volume -domain: data-api.binance.vision -strategy: public -browser: false - -args: - limit: - type: int - default: 20 - description: Number of tickers - -pipeline: - - fetch: - url: https://data-api.binance.vision/api/v3/ticker/24hr - - - map: - symbol: ${{ item.symbol }} - price: ${{ item.lastPrice }} - change_pct: ${{ item.priceChangePercent }} - high: ${{ item.highPrice }} - low: ${{ item.lowPrice }} - volume: ${{ item.volume }} - quote_vol: ${{ item.quoteVolume }} - trades: ${{ item.count }} - sort_volume: ${{ Number(item.quoteVolume) }} - - - sort: - by: sort_volume - order: desc - - - map: - symbol: ${{ item.symbol }} - price: ${{ item.lastPrice }} - change_pct: ${{ item.priceChangePercent }} - high: ${{ item.highPrice }} - low: ${{ item.lowPrice }} - volume: ${{ item.volume }} - quote_vol: ${{ item.quoteVolume }} - trades: ${{ item.count }} - - - limit: ${{ args.limit }} - -columns: [symbol, price, change_pct, high, low, volume, quote_vol, trades] diff --git a/src/clis/binance/top.ts b/src/clis/binance/top.ts new file mode 100644 index 00000000..1d13d959 --- /dev/null +++ b/src/clis/binance/top.ts @@ -0,0 +1,21 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'binance', + name: 'top', + description: 'Top trading pairs by 24h volume on Binance', + domain: 'data-api.binance.vision', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 20, help: 'Number of trading pairs' }, + ], + columns: ['rank', 'symbol', 'price', 'change_24h', 'high', 'low', 'volume'], + pipeline: [ + { fetch: { url: 'https://data-api.binance.vision/api/v3/ticker/24hr' } }, + { map: { symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_24h: '${{ item.priceChangePercent }}', high: '${{ item.highPrice }}', low: '${{ item.lowPrice }}', volume: '${{ item.quoteVolume }}', sort_volume: '${{ Number(item.quoteVolume) }}' } }, + { sort: { by: 'sort_volume', order: 'desc' } }, + { map: { rank: '${{ index + 1 }}', symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_24h: '${{ item.priceChangePercent }}', high: '${{ item.highPrice }}', low: '${{ item.lowPrice }}', volume: '${{ item.quoteVolume }}' } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/src/clis/binance/top.yaml b/src/clis/binance/top.yaml deleted file mode 100644 index b4a87130..00000000 --- a/src/clis/binance/top.yaml +++ /dev/null @@ -1,42 +0,0 @@ -site: binance -name: top -description: Top trading pairs by 24h volume on Binance -domain: data-api.binance.vision -strategy: public -browser: false - -args: - limit: - type: int - default: 20 - description: Number of trading pairs - -pipeline: - - fetch: - url: https://data-api.binance.vision/api/v3/ticker/24hr - - - map: - symbol: ${{ item.symbol }} - price: ${{ item.lastPrice }} - change_24h: ${{ item.priceChangePercent }} - high: ${{ item.highPrice }} - low: ${{ item.lowPrice }} - volume: ${{ item.quoteVolume }} - sort_volume: ${{ Number(item.quoteVolume) }} - - - sort: - by: sort_volume - order: desc - - - map: - rank: ${{ index + 1 }} - symbol: ${{ item.symbol }} - price: ${{ item.lastPrice }} - change_24h: ${{ item.priceChangePercent }} - high: ${{ item.highPrice }} - low: ${{ item.lowPrice }} - volume: ${{ item.quoteVolume }} - - - limit: ${{ args.limit }} - -columns: [rank, symbol, price, change_24h, high, low, volume] diff --git a/src/clis/binance/trades.ts b/src/clis/binance/trades.ts new file mode 100644 index 00000000..9fee85df --- /dev/null +++ b/src/clis/binance/trades.ts @@ -0,0 +1,20 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'binance', + name: 'trades', + description: 'Recent trades for a trading pair', + domain: 'data-api.binance.vision', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'symbol', type: 'str', required: true, positional: true, help: 'Trading pair symbol (e.g. BTCUSDT, ETHUSDT)' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of trades (max 1000)' }, + ], + columns: ['id', 'price', 'qty', 'quote_qty', 'buyer_maker'], + pipeline: [ + { fetch: { url: 'https://data-api.binance.vision/api/v3/trades?symbol=${{ args.symbol }}&limit=${{ args.limit }}' } }, + { map: { id: '${{ item.id }}', price: '${{ item.price }}', qty: '${{ item.qty }}', quote_qty: '${{ item.quoteQty }}', buyer_maker: '${{ item.isBuyerMaker }}' } }, + { limit: '${{ args.limit }}' }, + ], +}); diff --git a/src/clis/binance/trades.yaml b/src/clis/binance/trades.yaml deleted file mode 100644 index 958e2314..00000000 --- a/src/clis/binance/trades.yaml +++ /dev/null @@ -1,32 +0,0 @@ -site: binance -name: trades -description: Recent trades for a trading pair -domain: data-api.binance.vision -strategy: public -browser: false - -args: - symbol: - type: str - required: true - positional: true - description: "Trading pair symbol (e.g. BTCUSDT, ETHUSDT)" - limit: - type: int - default: 20 - description: Number of trades (max 1000) - -pipeline: - - fetch: - url: https://data-api.binance.vision/api/v3/trades?symbol=${{ args.symbol }}&limit=${{ args.limit }} - - - map: - id: ${{ item.id }} - price: ${{ item.price }} - qty: ${{ item.qty }} - quote_qty: ${{ item.quoteQty }} - buyer_maker: ${{ item.isBuyerMaker }} - - - limit: ${{ args.limit }} - -columns: [id, price, qty, quote_qty, buyer_maker] diff --git a/src/diagnostic.test.ts b/src/diagnostic.test.ts index 85892893..69bacfc0 100644 --- a/src/diagnostic.test.ts +++ b/src/diagnostic.test.ts @@ -103,8 +103,8 @@ describe('redactText', () => { describe('resolveAdapterSourcePath', () => { it('returns source when it is a real file path (not manifest:)', () => { - const cmd = makeCmd({ source: '/home/user/.opencli/clis/arxiv/search.yaml' }); - expect(resolveAdapterSourcePath(cmd as InternalCliCommand)).toBe('/home/user/.opencli/clis/arxiv/search.yaml'); + const cmd = makeCmd({ source: '/home/user/.opencli/clis/arxiv/search.ts' }); + expect(resolveAdapterSourcePath(cmd as InternalCliCommand)).toBe('/home/user/.opencli/clis/arxiv/search.ts'); }); it('skips manifest: pseudo-paths and falls back to _modulePath', () => { diff --git a/src/record.ts b/src/record.ts index 50f12a44..e16b9f95 100644 --- a/src/record.ts +++ b/src/record.ts @@ -16,7 +16,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as readline from 'node:readline'; import chalk from 'chalk'; -import yaml from 'js-yaml'; + import { sendCommand } from './browser/daemon-client.js'; import type { IPage } from './types.js'; import { SEARCH_PARAMS, PAGINATION_PARAMS, FIELD_ROLES } from './constants.js'; @@ -749,11 +749,11 @@ function analyzeAndWrite( if (usedNames.has(entry.name)) continue; usedNames.add(entry.name); - const filePath = path.join(candidatesDir, `${entry.name}.yaml`); - fs.writeFileSync(filePath, yaml.dump(entry.yaml, { sortKeys: false, lineWidth: 120 })); + const filePath = path.join(candidatesDir, `${entry.name}.json`); + fs.writeFileSync(filePath, JSON.stringify(entry.yaml, null, 2)); candidates.push({ name: entry.name, path: filePath, strategy: entry.strategy }); - console.log(chalk.green(` ✓ Generated: ${chalk.bold(entry.name)}.yaml [${entry.strategy}]`)); + console.log(chalk.green(` ✓ Generated: ${chalk.bold(entry.name)}.json [${entry.strategy}]`)); console.log(chalk.dim(` → ${filePath}`)); } diff --git a/src/skill-generate.test.ts b/src/skill-generate.test.ts index 77e83821..315c2015 100644 --- a/src/skill-generate.test.ts +++ b/src/skill-generate.test.ts @@ -21,7 +21,7 @@ describe('mapOutcomeToSkillOutput', () => { name: 'hot', command: 'demo/hot', strategy: Strategy.PUBLIC, - path: '/tmp/demo/hot.yaml', + path: '/tmp/demo/hot.verified.ts', metadata_path: '/tmp/demo/hot.meta.json', reusability: 'verified-artifact', }, @@ -35,7 +35,7 @@ describe('mapOutcomeToSkillOutput', () => { expect(result.reusability).toBe('verified-artifact'); expect(result.command).toBe('demo/hot'); expect(result.strategy).toBe('public'); - expect(result.path).toBe('/tmp/demo/hot.yaml'); + expect(result.path).toBe('/tmp/demo/hot.verified.ts'); expect(result.message).toContain('demo/hot'); expect(result.reason).toBeUndefined(); expect(result.suggested_action).toBeUndefined(); @@ -103,7 +103,7 @@ describe('mapOutcomeToSkillOutput', () => { candidate: { name: 'detail', command: 'demo/detail', - path: '/tmp/demo/detail.yaml', + path: '/tmp/demo/detail.verified.ts', reusability: 'unverified-candidate', }, }, @@ -118,7 +118,7 @@ describe('mapOutcomeToSkillOutput', () => { expect(result.reason).toBe('unsupported-required-args'); expect(result.suggested_action).toBe('ask-for-sample-arg'); expect(result.reusability).toBe('unverified-candidate'); - expect(result.path).toBe('/tmp/demo/detail.yaml'); + expect(result.path).toBe('/tmp/demo/detail.verified.ts'); expect(result.message).toContain('required args: id'); }); @@ -161,7 +161,7 @@ describe('mapOutcomeToSkillOutput', () => { candidate: { name: 'hot', command: 'demo/hot', - path: '/tmp/demo/hot.yaml', + path: '/tmp/demo/hot.verified.ts', reusability: 'unverified-candidate', }, }, @@ -174,8 +174,8 @@ describe('mapOutcomeToSkillOutput', () => { expect(result.conclusion).toBe('needs-human-check'); expect(result.reason).toBe('verify-inconclusive'); expect(result.suggested_action).toBe('manual-review'); - expect(result.path).toBe('/tmp/demo/hot.yaml'); - expect(result.message).toContain('/tmp/demo/hot.yaml'); + expect(result.path).toBe('/tmp/demo/hot.verified.ts'); + expect(result.message).toContain('/tmp/demo/hot.verified.ts'); }); it('output satisfies SkillOutput contract shape', () => { diff --git a/src/synthesize.ts b/src/synthesize.ts index 6ce74e1b..bdfef7e8 100644 --- a/src/synthesize.ts +++ b/src/synthesize.ts @@ -174,7 +174,7 @@ function buildTemplatedUrl(rawUrl: string, cap: SynthesizeCapability, _endpoint: /** * Build inline evaluate script for browser-based fetch+parse. - * Follows patterns from bilibili/hot.yaml and twitter/trending.yaml. + * Follows patterns from bilibili/hot.ts and twitter/trending.ts. */ function buildEvaluateScript(url: string, itemPath: string, endpoint: ExploreEndpointArtifact): string { const pathChain = itemPath.split('.').map((p: string) => `?.${p}`).join(''); From cc95f2f8b20443b284e546e53b3f20b5c769978b Mon Sep 17 00:00:00 2001 From: jackwener Date: Wed, 8 Apr 2026 22:53:57 +0800 Subject: [PATCH 5/5] fix: clean up remaining YAML adapter references in docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/zh/guide/plugins.md: replace YAML plugin example with TS pipeline - docs/developer/testing.md: YAML Adapter heading → Adapter, remove validate line - TESTING.md: same fix in root testing doc - CONTRIBUTING.md: remove "YAML validation" comment - docs/.vitepress/config.mts: mark YAML Adapter Guide as (Deprecated) in nav - docs/advanced/download.md: remove "YAML Adapters" from pipeline step heading --- CONTRIBUTING.md | 2 +- TESTING.md | 5 ++--- docs/.vitepress/config.mts | 2 +- docs/advanced/download.md | 4 ++-- docs/developer/testing.md | 5 ++--- docs/zh/guide/plugins.md | 31 ++++++++++++++++++------------- 6 files changed, 26 insertions(+), 23 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 207ed89e..0b4c6fb9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -188,7 +188,7 @@ Common scopes: site name (`twitter`, `reddit`) or module name (`browser`, `pipel npx tsc --noEmit # Type check npm test # Core unit tests npm run test:adapter # Focused adapter tests (if you touched adapter logic) - opencli validate # YAML validation (if applicable) + opencli validate # Adapter validation ``` 4. Commit using conventional commit format 5. Push and open a PR diff --git a/TESTING.md b/TESTING.md index b173c896..2c42fe57 100644 --- a/TESTING.md +++ b/TESTING.md @@ -132,10 +132,9 @@ npx vitest src/ ## 如何添加新测试 -### 新增 YAML Adapter(如 `clis/producthunt/trending.yaml`) +### 新增 Adapter(如 `clis/producthunt/trending.ts`) -1. `opencli validate` 的 E2E / smoke 测试会覆盖 adapter 结构校验 -2. 根据 adapter 类型,在对应测试文件补一个 `it()` block +1. 根据 adapter 类型,在对应测试文件补一个 `it()` block ```typescript // 如果 browser: false(公开 API)→ tests/e2e/public-commands.test.ts diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 4fdb104c..8d4ed75f 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -144,7 +144,7 @@ export default defineConfig({ { text: 'Contributing', link: '/developer/contributing' }, { text: 'Testing', link: '/developer/testing' }, { text: 'Architecture', link: '/developer/architecture' }, - { text: 'YAML Adapter Guide', link: '/developer/yaml-adapter' }, + { text: 'YAML Adapter Guide (Deprecated)', link: '/developer/yaml-adapter' }, { text: 'TypeScript Adapter Guide', link: '/developer/ts-adapter' }, { text: 'AI Workflow', link: '/developer/ai-workflow' }, ], diff --git a/docs/advanced/download.md b/docs/advanced/download.md index bc775717..c6ce47bf 100644 --- a/docs/advanced/download.md +++ b/docs/advanced/download.md @@ -53,9 +53,9 @@ opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --download-images opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin ``` -## Pipeline Step (YAML Adapters) +## Pipeline Step -The `download` step can be used in YAML pipelines: +The `download` step can be used in pipeline adapters: ::: v-pre ```yaml diff --git a/docs/developer/testing.md b/docs/developer/testing.md index bc91cacf..0003b84e 100644 --- a/docs/developer/testing.md +++ b/docs/developer/testing.md @@ -138,10 +138,9 @@ npx vitest src/ ## 如何添加新测试 -### 新增 YAML Adapter(如 `clis/producthunt/trending.yaml`) +### 新增 Adapter(如 `clis/producthunt/trending.ts`) -1. `opencli validate` 的 E2E / smoke 测试会覆盖 adapter 结构校验 -2. 根据 adapter 类型,在对应测试文件补一个 `it()` block +1. 根据 adapter 类型,在对应测试文件补一个 `it()` block ```typescript // 如果 browser: false(公开 API)→ tests/e2e/public-commands.test.ts diff --git a/docs/zh/guide/plugins.md b/docs/zh/guide/plugins.md index b7fd0946..a3bc7bf7 100644 --- a/docs/zh/guide/plugins.md +++ b/docs/zh/guide/plugins.md @@ -106,28 +106,33 @@ opencli plugin install github:user/opencli-plugins/polymarket OpenCLI 会把已安装 plugin 的版本记录到 `~/.opencli/plugins.lock.json`。每条记录会保存 plugin source、当前 git commit hash、安装时间,以及最近一次更新时间。只要有这份元数据,`opencli plugin list` 就会显示对应的短 commit hash。 -## YAML plugin 示例 +## Pipeline plugin 示例 ```text my-plugin/ - hot.yaml + package.json + hot.ts ``` -```yaml -site: my-plugin -name: hot -description: Example plugin command -strategy: public -browser: false +`hot.ts`: -pipeline: - - evaluate: | - () => [{ title: 'hello', url: 'https://example.com' }] +```typescript +import { cli, Strategy } from '@jackwener/opencli/registry'; -columns: [title, url] +cli({ + site: 'my-plugin', + name: 'hot', + description: 'Example plugin command', + strategy: Strategy.PUBLIC, + browser: false, + columns: ['title', 'url'], + pipeline: [ + { evaluate: `() => [{ title: 'hello', url: 'https://example.com' }]` }, + ], +}); ``` -## TypeScript plugin 示例 +## func() plugin 示例 ```text my-plugin/