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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
| **notebooklm** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` |
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` |
| **xianyu** | `search` `item` `chat` |
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` |

73+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**

Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 桌面端 |
| **chatgpt** | `status` `new` `send` `read` `ask` `model` | 桌面端 |
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` | 浏览器 |
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 公开 |
| **zhihu** | `hot` `search` `question` `download` | 浏览器 |
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export default defineConfig({
{ text: 'Bilibili', link: '/adapters/browser/bilibili' },
{ text: 'Zhihu', link: '/adapters/browser/zhihu' },
{ text: 'Xiaohongshu', link: '/adapters/browser/xiaohongshu' },
{ text: 'Xiaoe', link: '/adapters/browser/xiaoe' },
{ text: 'Weibo', link: '/adapters/browser/weibo' },
{ text: 'YouTube', link: '/adapters/browser/youtube' },
{ text: 'Xueqiu', link: '/adapters/browser/xueqiu' },
Expand Down
44 changes: 44 additions & 0 deletions docs/adapters/browser/xiaoe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Xiaoe (小鹅通)

**Mode**: 🔐 Browser · **Domain**: `study.xiaoe-tech.com` / `*.h5.xet.citv.cn`

## Commands

| Command | Description |
|---------|-------------|
| `opencli xiaoe courses` | List purchased courses with course URLs and shop names |
| `opencli xiaoe detail <url>` | Read course metadata such as title, price, student count, and shop |
| `opencli xiaoe catalog <url>` | Read the full course outline for normal courses, columns, and big columns |
| `opencli xiaoe play-url <url>` | Resolve the M3U8 playback URL for video lessons or live replays |
| `opencli xiaoe content <url>` | Extract rich-text lesson or page content as plain text |

## Usage Examples

```bash
# List purchased courses
opencli xiaoe courses --limit 10

# Read course metadata
opencli xiaoe detail "https://appxxxx.h5.xet.citv.cn/p/course/ecourse/v_xxxxx"

# Read the course outline
opencli xiaoe catalog "https://appxxxx.h5.xet.citv.cn/p/course/ecourse/v_xxxxx"

# Resolve a lesson M3U8 URL
opencli xiaoe play-url "https://appxxxx.h5.xet.citv.cn/v1/course/video/v_xxxxx?product_id=p_xxxxx" -f json

# Extract page content
opencli xiaoe content "https://appxxxx.h5.xet.citv.cn/v1/course/text/t_xxxxx"
```

## Prerequisites

- Chrome running and **logged into** the target Xiaoe shop
- [Browser Bridge extension](/guide/browser-bridge) installed

## Notes

- `courses` starts from `study.xiaoe-tech.com` and matches purchased course cards back to Vue data to recover shop names and course URLs
- `catalog` supports normal courses, columns, and big columns by reading Vuex / Vue component state after the course page loads
- `play-url` uses a direct API path for video lessons and falls back to runtime resource inspection for live replays
- Cross-shop course URLs are preserved, so you can take a URL from `courses` and pass it directly into `detail`, `catalog`, `play-url`, or `content`
1 change: 1 addition & 0 deletions docs/adapters/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Run `opencli list` for the live registry.
| **[bilibili](./browser/bilibili)** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 🔐 Browser |
| **[zhihu](./browser/zhihu)** | `hot` `search` `question` `download` | 🔐 Browser |
| **[xiaohongshu](./browser/xiaohongshu)** | `search` `notifications` `feed` `user` `note` `comments` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 🔐 Browser |
| **[xiaoe](./browser/xiaoe)** | `courses` `detail` `catalog` `play-url` `content` | 🔐 Browser |
| **[xueqiu](./browser/xueqiu)** | `feed` `hot-stock` `hot` `search` `stock` `comments` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | 🔐 Browser |
| **[youtube](./browser/youtube)** | `search` `video` `transcript` | 🔐 Browser |
| **[v2ex](./browser/v2ex)** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 🌐 / 🔐 |
Expand Down
129 changes: 129 additions & 0 deletions src/clis/xiaoe/catalog.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
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]
43 changes: 43 additions & 0 deletions src/clis/xiaoe/content.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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]
73 changes: 73 additions & 0 deletions src/clis/xiaoe/courses.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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]
39 changes: 39 additions & 0 deletions src/clis/xiaoe/detail.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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]
Loading