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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions docs/adapters/browser/zsxq.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# 知识星球 (ZSXQ)

**Mode**: 🔐 Browser · **Domain**: `wx.zsxq.com`

Read groups, topics, search results, dynamics, and single-topic details from [知识星球](https://wx.zsxq.com) using your logged-in Chrome session.

## Commands

| Command | Description |
|---------|-------------|
| `opencli zsxq groups` | List the groups your account has joined |
| `opencli zsxq topics` | List topics in the active group |
| `opencli zsxq topic <id>` | Fetch a single topic with comments |
| `opencli zsxq search <keyword>` | Search topics inside a group |
| `opencli zsxq dynamics` | List recent dynamics across groups |

## Usage Examples

```bash
# List your groups
opencli zsxq groups

# List topics from the active group in Chrome
opencli zsxq topics --limit 20

# Search inside the active group
opencli zsxq search "opencli"

# Search inside a specific group explicitly
opencli zsxq search "opencli" --group_id 123456789

# Export a single topic with comments
opencli zsxq topic 987654321 --comment_limit 20

# Read recent dynamics across all joined groups
opencli zsxq dynamics --limit 20
```

## Prerequisites

- Chrome running and **logged into** [wx.zsxq.com](https://wx.zsxq.com)
- [Browser Bridge extension](/guide/browser-bridge) installed

## Notes

- `zsxq topics` and `zsxq search` use the current active group context from Chrome by default
- If there is no active group context, pass `--group_id <id>` or open the target group in Chrome first
- `zsxq groups` returns `group_id`, which you can reuse with `--group_id`
- `zsxq topic` surfaces a missing topic as `NOT_FOUND` instead of a generic fetch error
29 changes: 29 additions & 0 deletions src/clis/zsxq/search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getRegistry } from '../../registry.js';
import './search.js';

describe('zsxq search command', () => {
beforeEach(() => {
vi.restoreAllMocks();
});

it('requires an explicit group_id when there is no active group context', async () => {
const command = getRegistry().get('zsxq/search');
expect(command?.func).toBeTypeOf('function');

const mockPage = {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn()
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(null),
} as any;

await expect(command!.func!(mockPage, { keyword: 'opencli', limit: 20 })).rejects.toMatchObject({
code: 'ARGUMENT',
message: 'Cannot determine active group_id',
});

expect(mockPage.goto).toHaveBeenCalledWith('https://wx.zsxq.com');
expect(mockPage.evaluate).toHaveBeenCalledTimes(2);
});
});
6 changes: 3 additions & 3 deletions src/clis/zsxq/search.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { cli, Strategy } from '../../registry.js';
import {
getActiveGroupId,
ensureZsxqAuth,
ensureZsxqPage,
fetchFirstJson,
getDefaultGroupId,
getGroupsFromResponse,
getTopicsFromResponse,
toTopicRow,
Expand All @@ -19,7 +19,7 @@ cli({
args: [
{ name: 'keyword', required: true, positional: true, help: 'Search keyword' },
{ name: 'limit', type: 'int', default: 20, help: 'Number of results to return' },
{ name: 'group_id', help: 'Optional group id; defaults to group_id cookie' },
{ name: 'group_id', help: 'Optional group id; defaults to the active group in Chrome' },
],
columns: ['topic_id', 'group', 'author', 'title', 'comments', 'likes', 'time', 'url'],
func: async (page, kwargs) => {
Expand All @@ -28,7 +28,7 @@ cli({

const keyword = String(kwargs.keyword || '').trim();
const limit = Math.max(1, Number(kwargs.limit) || 20);
const groupId = String(kwargs.group_id || await getDefaultGroupId(page));
const groupId = String(kwargs.group_id || await getActiveGroupId(page));
const query = encodeURIComponent(keyword);

// Resolve group name from groups API
Expand Down
29 changes: 29 additions & 0 deletions src/clis/zsxq/topics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getRegistry } from '../../registry.js';
import './topics.js';

describe('zsxq topics command', () => {
beforeEach(() => {
vi.restoreAllMocks();
});

it('requires an explicit group_id when there is no active group context', async () => {
const command = getRegistry().get('zsxq/topics');
expect(command?.func).toBeTypeOf('function');

const mockPage = {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn()
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(null),
} as any;

await expect(command!.func!(mockPage, { limit: 20 })).rejects.toMatchObject({
code: 'ARGUMENT',
message: 'Cannot determine active group_id',
});

expect(mockPage.goto).toHaveBeenCalledWith('https://wx.zsxq.com');
expect(mockPage.evaluate).toHaveBeenCalledTimes(2);
});
});
6 changes: 3 additions & 3 deletions src/clis/zsxq/topics.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { cli, Strategy } from '../../registry.js';
import {
getActiveGroupId,
ensureZsxqAuth,
ensureZsxqPage,
fetchFirstJson,
getDefaultGroupId,
getTopicsFromResponse,
toTopicRow,
} from './utils.js';
Expand All @@ -17,15 +17,15 @@ cli({
browser: true,
args: [
{ name: 'limit', type: 'int', default: 20, help: 'Number of topics to return' },
{ name: 'group_id', help: 'Optional group id; defaults to group_id cookie' },
{ name: 'group_id', help: 'Optional group id; defaults to the active group in Chrome' },
],
columns: ['topic_id', 'type', 'author', 'title', 'comments', 'likes', 'time', 'url'],
func: async (page, kwargs) => {
await ensureZsxqPage(page);
await ensureZsxqAuth(page);

const limit = Math.max(1, Number(kwargs.limit) || 20);
const groupId = String(kwargs.group_id || await getDefaultGroupId(page));
const groupId = String(kwargs.group_id || await getActiveGroupId(page));

const { data } = await fetchFirstJson(page, [
`https://api.zsxq.com/v2/groups/${groupId}/topics?scope=all&count=${limit}`,
Expand Down
44 changes: 6 additions & 38 deletions src/clis/zsxq/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AuthRequiredError, CliError } from '../../errors.js';
import { ArgumentError, AuthRequiredError, CliError } from '../../errors.js';
import type { IPage } from '../../types.js';

export interface ZsxqUser {
Expand Down Expand Up @@ -147,11 +147,9 @@ export async function getCookieValue(page: IPage, name: string): Promise<string
return cookies.find(cookie => cookie.name === name)?.value;
}

export async function getDefaultGroupId(page: IPage): Promise<string> {
// Try localStorage first, then fall back to first joined group
export async function getActiveGroupId(page: IPage): Promise<string> {
const groupId = await page.evaluate(`
(() => {
// Check localStorage
const target = localStorage.getItem('target_group');
if (target) {
try {
Expand All @@ -164,40 +162,10 @@ export async function getDefaultGroupId(page: IPage): Promise<string> {
`);
if (groupId) return groupId;

// Fall back: call API to get first group
const raw = await page.evaluate(`
(async () => {
try {
const r = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.zsxq.com/v2/groups', true);
xhr.withCredentials = true;
xhr.setRequestHeader('accept', 'application/json');
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try { resolve(JSON.parse(xhr.responseText)); }
catch { resolve(null); }
} else { resolve(null); }
};
xhr.onerror = () => resolve(null);
xhr.send();
});
if (r && r.resp_data && r.resp_data.groups && r.resp_data.groups.length > 0) {
return String(r.resp_data.groups[0].group_id);
}
return null;
} catch { return null; }
})()
`);

if (!raw) {
throw new CliError(
'AUTH_REQUIRED',
'Cannot determine group_id',
'Open a specific 知识星球 page in Chrome first',
);
}
return raw;
throw new ArgumentError(
'Cannot determine active group_id',
'Pass --group_id <id> or open the target 知识星球 page in Chrome first',
);
}

export async function browserJsonRequest(page: IPage, path: string): Promise<BrowserFetchResult> {
Expand Down