diff --git a/cli-manifest.json b/cli-manifest.json
new file mode 100644
index 000000000..2141e06ab
--- /dev/null
+++ b/cli-manifest.json
@@ -0,0 +1,5794 @@
+[
+ {
+ "site": "bilibili",
+ "name": "hot",
+ "description": "B站热门视频",
+ "domain": "www.bilibili.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "Number of videos"
+ }
+ ],
+ "columns": [
+ "rank",
+ "title",
+ "author",
+ "play",
+ "danmaku"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.bilibili.com"
+ },
+ {
+ "evaluate": "(async () => {\n const res = await fetch('https://api.bilibili.com/x/web-interface/popular?ps=${{ args.limit }}&pn=1', {\n credentials: 'include'\n });\n const data = await res.json();\n return (data?.data?.list || []).map((item) => ({\n title: item.title,\n author: item.owner?.name,\n play: item.stat?.view,\n danmaku: item.stat?.danmaku,\n }));\n})()\n"
+ },
+ {
+ "map": {
+ "rank": "${{ index + 1 }}",
+ "title": "${{ item.title }}",
+ "author": "${{ item.author }}",
+ "play": "${{ item.play }}",
+ "danmaku": "${{ item.danmaku }}"
+ }
+ },
+ {
+ "limit": "${{ args.limit }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "bilibili/hot.yaml"
+ },
+ {
+ "site": "bluesky",
+ "name": "feeds",
+ "description": "Popular Bluesky feed generators",
+ "domain": "public.api.bsky.app",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "bluesky/feeds.yaml"
+ },
+ {
+ "site": "bluesky",
+ "name": "followers",
+ "description": "List followers of a Bluesky user",
+ "domain": "public.api.bsky.app",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "handle",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Bluesky handle"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "bluesky/followers.yaml"
+ },
+ {
+ "site": "bluesky",
+ "name": "following",
+ "description": "List accounts a Bluesky user is following",
+ "domain": "public.api.bsky.app",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "handle",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Bluesky handle"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "bluesky/following.yaml"
+ },
+ {
+ "site": "bluesky",
+ "name": "profile",
+ "description": "Get Bluesky user profile info",
+ "domain": "public.api.bsky.app",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "handle",
+ "type": "str",
+ "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 }}"
+ }
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "bluesky/profile.yaml"
+ },
+ {
+ "site": "bluesky",
+ "name": "search",
+ "description": "Search Bluesky users",
+ "domain": "public.api.bsky.app",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "query",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Search query"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 10,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "bluesky/search.yaml"
+ },
+ {
+ "site": "bluesky",
+ "name": "starter-packs",
+ "description": "Get starter packs created by a Bluesky user",
+ "domain": "public.api.bsky.app",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "handle",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Bluesky handle"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 10,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "bluesky/starter-packs.yaml"
+ },
+ {
+ "site": "bluesky",
+ "name": "thread",
+ "description": "Get a Bluesky post thread with replies",
+ "domain": "public.api.bsky.app",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "uri",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Post AT URI (at://did:.../app.bsky.feed.post/...) or bsky.app URL"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "bluesky/thread.yaml"
+ },
+ {
+ "site": "bluesky",
+ "name": "trending",
+ "description": "Trending topics on Bluesky",
+ "domain": "public.api.bsky.app",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "bluesky/trending.yaml"
+ },
+ {
+ "site": "bluesky",
+ "name": "user",
+ "description": "Get recent posts from a Bluesky user",
+ "domain": "public.api.bsky.app",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "handle",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Bluesky handle (e.g. bsky.app)"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "bluesky/user.yaml"
+ },
+ {
+ "site": "devto",
+ "name": "tag",
+ "description": "Latest DEV.to articles for a specific tag",
+ "domain": "dev.to",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "tag",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Tag name (e.g. javascript, python, webdev)"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "devto/tag.yaml"
+ },
+ {
+ "site": "devto",
+ "name": "top",
+ "description": "Top DEV.to articles of the day",
+ "domain": "dev.to",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "devto/top.yaml"
+ },
+ {
+ "site": "devto",
+ "name": "user",
+ "description": "Recent DEV.to articles from a specific user",
+ "domain": "dev.to",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "DEV.to username (e.g. ben, thepracticaldev)"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "devto/user.yaml"
+ },
+ {
+ "site": "dictionary",
+ "name": "examples",
+ "description": "Read real-world example sentences utilizing the word",
+ "domain": "api.dictionaryapi.dev",
+ "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
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "dictionary/examples.yaml"
+ },
+ {
+ "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": [
+ {
+ "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
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "dictionary/search.yaml"
+ },
+ {
+ "site": "dictionary",
+ "name": "synonyms",
+ "description": "Find synonyms for a specific word",
+ "domain": "api.dictionaryapi.dev",
+ "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
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "dictionary/synonyms.yaml"
+ },
+ {
+ "site": "douban",
+ "name": "subject",
+ "description": "获取电影详情",
+ "domain": "movie.douban.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "id",
+ "type": "str",
+ "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 () => {\n const id = '${{ args.id }}';\n\n // Wait for page to load\n await new Promise(r => setTimeout(r, 2000));\n\n // Extract title - v:itemreviewed contains \"中文名 OriginalName\"\n const titleEl = document.querySelector('span[property=\"v:itemreviewed\"]');\n const fullTitle = titleEl?.textContent?.trim() || '';\n\n // Split title and originalTitle\n // Douban format: \"中文名 OriginalName\" - split by first space that separates CJK from non-CJK\n let title = fullTitle;\n let originalTitle = '';\n const titleMatch = fullTitle.match(/^([\\u4e00-\\u9fff\\u3000-\\u303f\\uff00-\\uffef]+(?:\\s*[\\u4e00-\\u9fff\\u3000-\\u303f\\uff00-\\uffef·::!?]+)*)\\s+(.+)$/);\n if (titleMatch) {\n title = titleMatch[1].trim();\n originalTitle = titleMatch[2].trim();\n }\n\n // Extract year\n const yearEl = document.querySelector('.year');\n const year = yearEl?.textContent?.trim().replace(/[()()]/g, '') || '';\n\n // Extract rating\n const ratingEl = document.querySelector('strong[property=\"v:average\"]');\n const rating = parseFloat(ratingEl?.textContent || '0');\n\n // Extract rating count\n const ratingCountEl = document.querySelector('span[property=\"v:votes\"]');\n const ratingCount = parseInt(ratingCountEl?.textContent || '0', 10);\n\n // Extract genres\n const genreEls = document.querySelectorAll('span[property=\"v:genre\"]');\n const genres = Array.from(genreEls).map(el => el.textContent?.trim()).filter(Boolean).join(',');\n\n // Extract directors\n const directorEls = document.querySelectorAll('a[rel=\"v:directedBy\"]');\n const directors = Array.from(directorEls).map(el => el.textContent?.trim()).filter(Boolean).join(',');\n\n // Extract casts\n const castEls = document.querySelectorAll('a[rel=\"v:starring\"]');\n const casts = Array.from(castEls).slice(0, 5).map(el => el.textContent?.trim()).filter(Boolean);\n\n // Extract info section for country and duration\n const infoEl = document.querySelector('#info');\n const infoText = infoEl?.textContent || '';\n\n // Extract country/region from #info as list\n let country = [];\n const countryMatch = infoText.match(/制片国家\\/地区:\\s*([^\\n]+)/);\n if (countryMatch) {\n country = countryMatch[1].trim().split(/\\s*\\/\\s*/).filter(Boolean);\n }\n\n // Extract duration from #info as pure number in min\n const durationEl = document.querySelector('span[property=\"v:runtime\"]');\n let durationRaw = durationEl?.textContent?.trim() || '';\n if (!durationRaw) {\n const durationMatch = infoText.match(/片长:\\s*([^\\n]+)/);\n if (durationMatch) {\n durationRaw = durationMatch[1].trim();\n }\n }\n const durationNumMatch = durationRaw.match(/(\\d+)/);\n const duration = durationNumMatch ? parseInt(durationNumMatch[1], 10) : null;\n\n // Extract summary\n const summaryEl = document.querySelector('span[property=\"v:summary\"]');\n const summary = summaryEl?.textContent?.trim() || '';\n\n return [{\n id,\n title,\n originalTitle,\n year,\n rating,\n ratingCount,\n genres,\n directors,\n casts,\n country,\n duration,\n summary: summary.substring(0, 200),\n url: `https://movie.douban.com/subject/${id}`\n }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "douban/subject.yaml"
+ },
+ {
+ "site": "douban",
+ "name": "top250",
+ "description": "豆瓣电影 Top250",
+ "domain": "movie.douban.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 250,
+ "required": false,
+ "positional": false,
+ "help": "返回结果数量"
+ }
+ ],
+ "columns": [
+ "rank",
+ "id",
+ "title",
+ "rating",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://movie.douban.com/top250"
+ },
+ {
+ "evaluate": "async () => {\n const results = [];\n const limit = ${{ args.limit }};\n\n const parsePage = (doc) => {\n const items = doc.querySelectorAll('.item');\n for (const item of items) {\n if (results.length >= limit) break;\n\n const rankEl = item.querySelector('.pic em');\n const linkEl = item.querySelector('a');\n const titleEl = item.querySelector('.title');\n const ratingEl = item.querySelector('.rating_num');\n\n const href = linkEl?.href || '';\n const matchResult = href.match(/subject\\/(\\d+)/);\n const id = matchResult ? matchResult[1] : '';\n\n const title = titleEl?.textContent?.trim() || '';\n const rank = parseInt(rankEl?.textContent || '0', 10);\n const rating = ratingEl?.textContent?.trim() || '';\n\n if (id && title) {\n results.push({\n rank: rank || results.length + 1,\n id,\n title,\n rating: rating ? parseFloat(rating) : 0,\n url: href\n });\n }\n }\n };\n\n parsePage(document);\n\n for (let start = 25; start < 250 && results.length < limit; start += 25) {\n const resp = await fetch(`https://movie.douban.com/top250?start=${start}`);\n if (!resp.ok) break;\n const html = await resp.text();\n if (!html) break;\n\n const doc = new DOMParser().parseFromString(html, 'text/html');\n parsePage(doc);\n await new Promise(r => setTimeout(r, 150));\n }\n\n return results;\n}\n"
+ },
+ {
+ "limit": "${{ args.limit }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "douban/top250.yaml"
+ },
+ {
+ "site": "facebook",
+ "name": "add-friend",
+ "description": "Send a friend request on Facebook",
+ "domain": "www.facebook.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "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 () => {\n const username = ${{ args.username | json }};\n // Find \"Add Friend\" button\n const buttons = Array.from(document.querySelectorAll('[role=\"button\"]'));\n const addBtn = buttons.find(b => {\n const text = b.textContent.trim();\n return text === '加好友' || text === 'Add Friend' || text === 'Add friend';\n });\n\n if (!addBtn) {\n // Check if already friends\n const isFriend = buttons.some(b => {\n const t = b.textContent.trim();\n return t === '好友' || t === 'Friends' || t.includes('已发送') || t.includes('Pending');\n });\n if (isFriend) return [{ status: 'Already friends or request pending', username }];\n return [{ status: 'Add Friend button not found', username }];\n }\n\n addBtn.click();\n await new Promise(r => setTimeout(r, 1500));\n return [{ status: 'Friend request sent', username }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "facebook/add-friend.yaml"
+ },
+ {
+ "site": "facebook",
+ "name": "events",
+ "description": "Browse Facebook event categories",
+ "domain": "www.facebook.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 15,
+ "required": false,
+ "positional": false,
+ "help": "Number of categories"
+ }
+ ],
+ "columns": [
+ "index",
+ "name"
+ ],
+ "pipeline": [
+ {
+ "navigate": {
+ "url": "https://www.facebook.com/events",
+ "settleMs": 3000
+ }
+ },
+ {
+ "evaluate": "(() => {\n const limit = ${{ args.limit }};\n // Try actual event items first\n const articles = document.querySelectorAll('[role=\"article\"]');\n if (articles.length > 0) {\n return Array.from(articles).slice(0, limit).map((el, i) => ({\n index: i + 1,\n name: el.textContent.trim().replace(/\\s+/g, ' ').substring(0, 120),\n }));\n }\n\n // List event categories from sidebar navigation\n const links = Array.from(document.querySelectorAll('[role=\"navigation\"] a'))\n .filter(a => {\n const href = a.href || '';\n const text = a.textContent.trim();\n return href.includes('/events/') && text.length > 1 && text.length < 60 &&\n !href.includes('create');\n });\n\n return links.slice(0, limit).map((a, i) => ({\n index: i + 1,\n name: a.textContent.trim(),\n }));\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "facebook/events.yaml"
+ },
+ {
+ "site": "facebook",
+ "name": "feed",
+ "description": "Get your Facebook news feed",
+ "domain": "www.facebook.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 10,
+ "required": false,
+ "positional": false,
+ "help": "Number of posts"
+ }
+ ],
+ "columns": [
+ "index",
+ "author",
+ "content",
+ "likes",
+ "comments",
+ "shares"
+ ],
+ "pipeline": [
+ {
+ "navigate": {
+ "url": "https://www.facebook.com/",
+ "settleMs": 4000
+ }
+ },
+ {
+ "evaluate": "(() => {\n const limit = ${{ args.limit }};\n const posts = document.querySelectorAll('[role=\"article\"]');\n return Array.from(posts)\n .filter(el => {\n const text = el.textContent.trim();\n // Filter out \"People you may know\" suggestions (both CN and EN)\n return text.length > 30 &&\n !text.startsWith('可能认识') &&\n !text.startsWith('People you may know') &&\n !text.startsWith('People You May Know');\n })\n .slice(0, limit)\n .map((el, i) => {\n // Author from header link\n const headerLink = el.querySelector('h2 a, h3 a, h4 a, strong a');\n const author = headerLink ? headerLink.textContent.trim() : '';\n\n // Post text: grab visible spans, filter noise\n const spans = Array.from(el.querySelectorAll('div[dir=\"auto\"]'))\n .map(s => s.textContent.trim())\n .filter(t => t.length > 10 && t.length < 500);\n const content = spans.length > 0 ? spans[0] : '';\n\n // Engagement: find like/comment/share counts (CN + EN)\n const allText = el.textContent;\n const likesMatch = allText.match(/所有心情:([\\d,.\\s]*[\\d万亿KMk]+)/) ||\n allText.match(/All:\\s*([\\d,.KMk]+)/) ||\n allText.match(/([\\d,.KMk]+)\\s*(?:likes?|reactions?)/i);\n const commentsMatch = allText.match(/([\\d,.]+\\s*[万亿]?)\\s*条评论/) ||\n allText.match(/([\\d,.KMk]+)\\s*comments?/i);\n const sharesMatch = allText.match(/([\\d,.]+\\s*[万亿]?)\\s*次分享/) ||\n allText.match(/([\\d,.KMk]+)\\s*shares?/i);\n\n return {\n index: i + 1,\n author: author.substring(0, 50),\n content: content.replace(/\\n/g, ' ').substring(0, 120),\n likes: likesMatch ? likesMatch[1] : '-',\n comments: commentsMatch ? commentsMatch[1] : '-',\n shares: sharesMatch ? sharesMatch[1] : '-',\n };\n });\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "facebook/feed.yaml"
+ },
+ {
+ "site": "facebook",
+ "name": "friends",
+ "description": "Get Facebook friend suggestions",
+ "domain": "www.facebook.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 10,
+ "required": false,
+ "positional": false,
+ "help": "Number of friend suggestions"
+ }
+ ],
+ "columns": [
+ "index",
+ "name",
+ "mutual"
+ ],
+ "pipeline": [
+ {
+ "navigate": {
+ "url": "https://www.facebook.com/friends",
+ "settleMs": 3000
+ }
+ },
+ {
+ "evaluate": "(() => {\n const limit = ${{ args.limit }};\n const items = document.querySelectorAll('[role=\"listitem\"]');\n return Array.from(items)\n .slice(0, limit)\n .map((el, i) => {\n const text = el.textContent.trim().replace(/\\s+/g, ' ');\n // Extract mutual info if present (before name extraction to avoid pollution)\n const mutualMatch = text.match(/([\\d,]+)\\s*位.*(?:关注|共同|mutual)/);\n // Extract name: remove mutual info, action buttons, etc.\n let name = text\n .replace(/[\\d,]+\\s*位.*(?:关注了|共同好友|mutual friends?)/, '')\n .replace(/加好友.*/, '').replace(/Add [Ff]riend.*/, '')\n .replace(/移除$/, '').replace(/Remove$/, '')\n .trim();\n return {\n index: i + 1,\n name: name.substring(0, 50),\n mutual: mutualMatch ? mutualMatch[1] : '-',\n };\n })\n .filter(item => item.name.length > 0);\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "facebook/friends.yaml"
+ },
+ {
+ "site": "facebook",
+ "name": "groups",
+ "description": "List your Facebook groups",
+ "domain": "www.facebook.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "Number of groups"
+ }
+ ],
+ "columns": [
+ "index",
+ "name",
+ "last_post",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": {
+ "url": "https://www.facebook.com/groups/feed/",
+ "settleMs": 3000
+ }
+ },
+ {
+ "evaluate": "(() => {\n const limit = ${{ args.limit }};\n const links = Array.from(document.querySelectorAll('a'))\n .filter(a => {\n const href = a.href || '';\n return href.includes('/groups/') &&\n !href.includes('/feed') &&\n !href.includes('/discover') &&\n !href.includes('/joins') &&\n !href.includes('category=create') &&\n a.textContent.trim().length > 2;\n });\n\n // Deduplicate by href\n const seen = new Set();\n const groups = [];\n for (const a of links) {\n const href = a.href.split('?')[0];\n if (seen.has(href)) continue;\n seen.add(href);\n const raw = a.textContent.trim().replace(/\\s+/g, ' ');\n // Split name from \"上次发帖\" info\n const parts = raw.split(/上次发帖|Last post/);\n groups.push({\n name: (parts[0] || '').trim().substring(0, 60),\n last_post: parts[1] ? parts[1].replace(/^[::]/, '').trim() : '-',\n url: href,\n });\n }\n return groups.slice(0, limit).map((g, i) => ({ index: i + 1, ...g }));\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "facebook/groups.yaml"
+ },
+ {
+ "site": "facebook",
+ "name": "join-group",
+ "description": "Join a Facebook group",
+ "domain": "www.facebook.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "group",
+ "type": "str",
+ "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 () => {\n const group = ${{ args.group | json }};\n const groupName = document.querySelector('h1')?.textContent?.trim() || group;\n\n // Find \"Join Group\" button\n const buttons = Array.from(document.querySelectorAll('[role=\"button\"]'));\n const joinBtn = buttons.find(b => {\n const text = b.textContent.trim();\n return text === '加入小组' || text === 'Join group' || text === 'Join Group';\n });\n\n if (!joinBtn) {\n const isMember = buttons.some(b => {\n const t = b.textContent.trim();\n return t === '已加入' || t === 'Joined' || t === '成员' || t === 'Member';\n });\n if (isMember) return [{ status: 'Already a member', group: groupName }];\n return [{ status: 'Join button not found', group: groupName }];\n }\n\n joinBtn.click();\n await new Promise(r => setTimeout(r, 1500));\n return [{ status: 'Join request sent', group: groupName }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "facebook/join-group.yaml"
+ },
+ {
+ "site": "facebook",
+ "name": "memories",
+ "description": "Get your Facebook memories (On This Day)",
+ "domain": "www.facebook.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 10,
+ "required": false,
+ "positional": false,
+ "help": "Number of memories"
+ }
+ ],
+ "columns": [
+ "index",
+ "source",
+ "content",
+ "time"
+ ],
+ "pipeline": [
+ {
+ "navigate": {
+ "url": "https://www.facebook.com/onthisday",
+ "settleMs": 4000
+ }
+ },
+ {
+ "evaluate": "(() => {\n const limit = ${{ args.limit }};\n const articles = document.querySelectorAll('[role=\"article\"]');\n return Array.from(articles)\n .slice(0, limit)\n .map((el, i) => {\n const headerLink = el.querySelector('h2 a, h3 a, h4 a, strong a');\n const spans = Array.from(el.querySelectorAll('div[dir=\"auto\"]'))\n .map(s => s.textContent.trim())\n .filter(t => t.length > 5 && t.length < 500);\n const timeEl = el.querySelector('a[href*=\"/posts/\"] span, a[href*=\"story_fbid\"] span');\n return {\n index: i + 1,\n source: headerLink ? headerLink.textContent.trim().substring(0, 50) : '-',\n content: (spans[0] || '').replace(/\\n/g, ' ').substring(0, 150),\n time: timeEl ? timeEl.textContent.trim() : '-',\n };\n })\n .filter(item => item.content.length > 0 || item.source !== '-');\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "facebook/memories.yaml"
+ },
+ {
+ "site": "facebook",
+ "name": "notifications",
+ "description": "Get recent Facebook notifications",
+ "domain": "www.facebook.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 15,
+ "required": false,
+ "positional": false,
+ "help": "Number of notifications"
+ }
+ ],
+ "columns": [
+ "index",
+ "text",
+ "time"
+ ],
+ "pipeline": [
+ {
+ "navigate": {
+ "url": "https://www.facebook.com/notifications",
+ "settleMs": 3000
+ }
+ },
+ {
+ "evaluate": "(() => {\n const limit = ${{ args.limit }};\n const items = document.querySelectorAll('[role=\"listitem\"]');\n return Array.from(items)\n .filter(el => el.querySelectorAll('a').length > 0)\n .slice(0, limit)\n .map((el, i) => {\n const raw = el.textContent.trim().replace(/\\s+/g, ' ');\n // Remove leading \"未读\" and trailing \"标记为已读\"\n const cleaned = raw.replace(/^未读/, '').replace(/标记为已读$/, '').replace(/^Unread/, '').replace(/Mark as read$/, '').trim();\n // Try to extract time (last segment like \"11小时\", \"5天\", \"1周\")\n const timeMatch = cleaned.match(/(\\d+\\s*(?:分钟|小时|天|周|个月|minutes?|hours?|days?|weeks?|months?))\\s*$/);\n const time = timeMatch ? timeMatch[1] : '';\n const text = timeMatch ? cleaned.slice(0, -timeMatch[0].length).trim() : cleaned;\n return {\n index: i + 1,\n text: text.substring(0, 150),\n time: time || '-',\n };\n });\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "facebook/notifications.yaml"
+ },
+ {
+ "site": "facebook",
+ "name": "profile",
+ "description": "Get Facebook user/page profile info",
+ "domain": "www.facebook.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "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": "(() => {\n const h1 = document.querySelector('h1');\n let name = h1 ? h1.textContent.trim() : '';\n\n // Find friends/followers links\n const links = Array.from(document.querySelectorAll('a'));\n const friendsLink = links.find(a => a.href && a.href.includes('/friends'));\n const followersLink = links.find(a => a.href && a.href.includes('/followers'));\n\n return [{\n name: name,\n username: ${{ args.username | json }},\n friends: friendsLink ? friendsLink.textContent.trim() : '-',\n followers: followersLink ? followersLink.textContent.trim() : '-',\n url: window.location.href,\n }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "facebook/profile.yaml"
+ },
+ {
+ "site": "facebook",
+ "name": "search",
+ "description": "Search Facebook for people, pages, or posts",
+ "domain": "www.facebook.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "query",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Search query"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 10,
+ "required": false,
+ "positional": false,
+ "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 () => {\n const limit = ${{ args.limit }};\n // Search results are typically in role=\"article\" or role=\"listitem\"\n let items = document.querySelectorAll('[role=\"article\"]');\n if (items.length === 0) {\n items = document.querySelectorAll('[role=\"listitem\"]');\n }\n return Array.from(items)\n .filter(el => el.textContent.trim().length > 20)\n .slice(0, limit)\n .map((el, i) => {\n const link = el.querySelector('a[href*=\"facebook.com/\"]');\n const heading = el.querySelector('h2, h3, h4, strong');\n return {\n index: i + 1,\n title: heading ? heading.textContent.trim().substring(0, 80) : '',\n text: el.textContent.trim().replace(/\\s+/g, ' ').substring(0, 150),\n url: link ? link.href.split('?')[0] : '',\n };\n });\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "facebook/search.yaml"
+ },
+ {
+ "site": "hackernews",
+ "name": "ask",
+ "description": "Hacker News Ask HN posts",
+ "domain": "news.ycombinator.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "hackernews/ask.yaml"
+ },
+ {
+ "site": "hackernews",
+ "name": "best",
+ "description": "Hacker News best stories",
+ "domain": "news.ycombinator.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "hackernews/best.yaml"
+ },
+ {
+ "site": "hackernews",
+ "name": "jobs",
+ "description": "Hacker News job postings",
+ "domain": "news.ycombinator.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "hackernews/jobs.yaml"
+ },
+ {
+ "site": "hackernews",
+ "name": "new",
+ "description": "Hacker News newest stories",
+ "domain": "news.ycombinator.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "hackernews/new.yaml"
+ },
+ {
+ "site": "hackernews",
+ "name": "search",
+ "description": "Search Hacker News stories",
+ "domain": "news.ycombinator.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "query",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Search query"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "Number of results"
+ },
+ {
+ "name": "sort",
+ "type": "str",
+ "default": "relevance",
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "hackernews/search.yaml"
+ },
+ {
+ "site": "hackernews",
+ "name": "show",
+ "description": "Hacker News Show HN posts",
+ "domain": "news.ycombinator.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "hackernews/show.yaml"
+ },
+ {
+ "site": "hackernews",
+ "name": "top",
+ "description": "Hacker News top stories",
+ "domain": "news.ycombinator.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "hackernews/top.yaml"
+ },
+ {
+ "site": "hackernews",
+ "name": "user",
+ "description": "Hacker News user profile",
+ "domain": "news.ycombinator.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "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 }}"
+ }
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "hackernews/user.yaml"
+ },
+ {
+ "site": "hupu",
+ "name": "hot",
+ "description": "虎扑热门帖子",
+ "domain": "bbs.hupu.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "Number of hot posts"
+ }
+ ],
+ "columns": [
+ "rank",
+ "title",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://bbs.hupu.com/"
+ },
+ {
+ "evaluate": "(async () => {\n // 从HTML中提取帖子信息(适配新的HTML结构)\n const html = document.documentElement.outerHTML;\n const posts = [];\n\n // 匹配当前虎扑页面结构的正则表达式\n // 结构: 标题\n const regex = /]*href=\"\\/(\\d{9})\\.html\"[^>]*>]*class=\"t-title\"[^>]*>([^<]+)<\\/span><\\/a>/g;\n let match;\n\n while ((match = regex.exec(html)) !== null && posts.length < ${{ args.limit }}) {\n posts.push({\n tid: match[1],\n title: match[2].trim()\n });\n }\n\n return posts;\n})()\n"
+ },
+ {
+ "map": {
+ "rank": "${{ index + 1 }}",
+ "title": "${{ item.title }}",
+ "url": "https://bbs.hupu.com/${{ item.tid }}.html"
+ }
+ },
+ {
+ "limit": "${{ args.limit }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "hupu/hot.yaml"
+ },
+ {
+ "site": "instagram",
+ "name": "comment",
+ "description": "Comment on an Instagram post",
+ "domain": "www.instagram.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Username of the post author"
+ },
+ {
+ "name": "text",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Comment text"
+ },
+ {
+ "name": "index",
+ "type": "int",
+ "default": 1,
+ "required": false,
+ "positional": false,
+ "help": "Post index (1 = most recent)"
+ }
+ ],
+ "columns": [
+ "status",
+ "user",
+ "text"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.instagram.com"
+ },
+ {
+ "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const commentText = ${{ args.text | json }};\n const idx = ${{ args.index }} - 1;\n const headers = { 'X-IG-App-ID': '936619743392459' };\n const opts = { credentials: 'include', headers };\n\n const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts);\n if (!r1.ok) throw new Error('User not found: ' + username);\n const userId = (await r1.json())?.data?.user?.id;\n\n const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts);\n const posts = (await r2.json())?.items || [];\n if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found');\n const pk = posts[idx].pk;\n\n const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';\n const r3 = await fetch('https://www.instagram.com/api/v1/web/comments/' + pk + '/add/', {\n method: 'POST', credentials: 'include',\n headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' },\n body: 'comment_text=' + encodeURIComponent(commentText),\n });\n if (!r3.ok) throw new Error('Failed to comment: HTTP ' + r3.status);\n return [{ status: 'Commented', user: username, text: commentText }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "instagram/comment.yaml"
+ },
+ {
+ "site": "instagram",
+ "name": "explore",
+ "description": "Instagram explore/discover trending posts",
+ "domain": "www.instagram.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "Number of posts"
+ }
+ ],
+ "columns": [
+ "rank",
+ "user",
+ "caption",
+ "likes",
+ "comments",
+ "type"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.instagram.com"
+ },
+ {
+ "evaluate": "(async () => {\n const limit = ${{ args.limit }};\n const res = await fetch(\n 'https://www.instagram.com/api/v1/discover/web/explore_grid/',\n {\n credentials: 'include',\n headers: { 'X-IG-App-ID': '936619743392459' }\n }\n );\n if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram');\n const data = await res.json();\n const posts = [];\n for (const sec of (data?.sectional_items || [])) {\n for (const m of (sec?.layout_content?.medias || [])) {\n const media = m?.media;\n if (media) posts.push({\n user: media.user?.username || '',\n caption: (media.caption?.text || '').replace(/\\n/g, ' ').substring(0, 100),\n likes: media.like_count ?? 0,\n comments: media.comment_count ?? 0,\n type: media.media_type === 1 ? 'photo' : media.media_type === 2 ? 'video' : 'carousel',\n });\n }\n }\n return posts.slice(0, limit).map((p, i) => ({ rank: i + 1, ...p }));\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "instagram/explore.yaml"
+ },
+ {
+ "site": "instagram",
+ "name": "follow",
+ "description": "Follow an Instagram user",
+ "domain": "www.instagram.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Instagram username to follow"
+ }
+ ],
+ "columns": [
+ "status",
+ "username"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.instagram.com"
+ },
+ {
+ "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const headers = { 'X-IG-App-ID': '936619743392459' };\n const opts = { credentials: 'include', headers };\n\n // Get user ID\n const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts);\n if (!r1.ok) throw new Error('User not found: ' + username);\n const d1 = await r1.json();\n const userId = d1?.data?.user?.id;\n if (!userId) throw new Error('User not found: ' + username);\n\n const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';\n const r2 = await fetch('https://www.instagram.com/api/v1/friendships/create/' + userId + '/', {\n method: 'POST',\n credentials: 'include',\n headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' },\n });\n if (!r2.ok) throw new Error('Failed to follow: HTTP ' + r2.status);\n const d2 = await r2.json();\n const status = d2?.friendship_status?.following ? 'Following' : d2?.friendship_status?.outgoing_request ? 'Request sent' : 'Followed';\n return [{ status, username }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "instagram/follow.yaml"
+ },
+ {
+ "site": "instagram",
+ "name": "followers",
+ "description": "List followers of an Instagram user",
+ "domain": "www.instagram.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Instagram username"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "Number of followers"
+ }
+ ],
+ "columns": [
+ "rank",
+ "username",
+ "name",
+ "verified",
+ "private"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.instagram.com"
+ },
+ {
+ "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const limit = ${{ args.limit }};\n const headers = { 'X-IG-App-ID': '936619743392459' };\n const opts = { credentials: 'include', headers };\n\n const r1 = await fetch(\n 'https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username),\n opts\n );\n if (!r1.ok) throw new Error('HTTP ' + r1.status + ' - make sure you are logged in to Instagram');\n const d1 = await r1.json();\n const userId = d1?.data?.user?.id;\n if (!userId) throw new Error('User not found: ' + username);\n\n const r2 = await fetch(\n 'https://www.instagram.com/api/v1/friendships/' + userId + '/followers/?count=' + limit,\n opts\n );\n if (!r2.ok) throw new Error('Failed to fetch followers: HTTP ' + r2.status);\n const d2 = await r2.json();\n return (d2?.users || []).slice(0, limit).map((u, i) => ({\n rank: i + 1,\n username: u.username || '',\n name: u.full_name || '',\n verified: u.is_verified ? 'Yes' : 'No',\n private: u.is_private ? 'Yes' : 'No',\n }));\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "instagram/followers.yaml"
+ },
+ {
+ "site": "instagram",
+ "name": "following",
+ "description": "List accounts an Instagram user is following",
+ "domain": "www.instagram.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Instagram username"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "Number of accounts"
+ }
+ ],
+ "columns": [
+ "rank",
+ "username",
+ "name",
+ "verified",
+ "private"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.instagram.com"
+ },
+ {
+ "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const limit = ${{ args.limit }};\n const headers = { 'X-IG-App-ID': '936619743392459' };\n const opts = { credentials: 'include', headers };\n\n const r1 = await fetch(\n 'https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username),\n opts\n );\n if (!r1.ok) throw new Error('HTTP ' + r1.status + ' - make sure you are logged in to Instagram');\n const d1 = await r1.json();\n const userId = d1?.data?.user?.id;\n if (!userId) throw new Error('User not found: ' + username);\n\n const r2 = await fetch(\n 'https://www.instagram.com/api/v1/friendships/' + userId + '/following/?count=' + limit,\n opts\n );\n if (!r2.ok) throw new Error('Failed to fetch following: HTTP ' + r2.status);\n const d2 = await r2.json();\n return (d2?.users || []).slice(0, limit).map((u, i) => ({\n rank: i + 1,\n username: u.username || '',\n name: u.full_name || '',\n verified: u.is_verified ? 'Yes' : 'No',\n private: u.is_private ? 'Yes' : 'No',\n }));\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "instagram/following.yaml"
+ },
+ {
+ "site": "instagram",
+ "name": "like",
+ "description": "Like an Instagram post",
+ "domain": "www.instagram.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Username of the post author"
+ },
+ {
+ "name": "index",
+ "type": "int",
+ "default": 1,
+ "required": false,
+ "positional": false,
+ "help": "Post index (1 = most recent)"
+ }
+ ],
+ "columns": [
+ "status",
+ "user",
+ "post"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.instagram.com"
+ },
+ {
+ "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const idx = ${{ args.index }} - 1;\n const headers = { 'X-IG-App-ID': '936619743392459' };\n const opts = { credentials: 'include', headers };\n\n const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts);\n if (!r1.ok) throw new Error('User not found: ' + username);\n const userId = (await r1.json())?.data?.user?.id;\n\n const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts);\n const posts = (await r2.json())?.items || [];\n if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found, user has ' + posts.length + ' recent posts');\n const pk = posts[idx].pk;\n const caption = (posts[idx].caption?.text || '').substring(0, 60);\n\n const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';\n const r3 = await fetch('https://www.instagram.com/api/v1/web/likes/' + pk + '/like/', {\n method: 'POST', credentials: 'include',\n headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' },\n });\n if (!r3.ok) throw new Error('Failed to like: HTTP ' + r3.status);\n return [{ status: 'Liked', user: username, post: caption || '(post #' + (idx+1) + ')' }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "instagram/like.yaml"
+ },
+ {
+ "site": "instagram",
+ "name": "profile",
+ "description": "Get Instagram user profile info",
+ "domain": "www.instagram.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Instagram username"
+ }
+ ],
+ "columns": [
+ "username",
+ "name",
+ "followers",
+ "following",
+ "posts",
+ "verified",
+ "bio"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.instagram.com"
+ },
+ {
+ "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const res = await fetch(\n 'https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username),\n {\n credentials: 'include',\n headers: { 'X-IG-App-ID': '936619743392459' }\n }\n );\n if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram');\n const data = await res.json();\n const u = data?.data?.user;\n if (!u) throw new Error('User not found: ' + username);\n return [{\n username: u.username,\n name: u.full_name || '',\n bio: (u.biography || '').replace(/\\n/g, ' ').substring(0, 120),\n followers: u.edge_followed_by?.count ?? 0,\n following: u.edge_follow?.count ?? 0,\n posts: u.edge_owner_to_timeline_media?.count ?? 0,\n verified: u.is_verified ? 'Yes' : 'No',\n url: 'https://www.instagram.com/' + u.username,\n }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "instagram/profile.yaml"
+ },
+ {
+ "site": "instagram",
+ "name": "save",
+ "description": "Save (bookmark) an Instagram post",
+ "domain": "www.instagram.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Username of the post author"
+ },
+ {
+ "name": "index",
+ "type": "int",
+ "default": 1,
+ "required": false,
+ "positional": false,
+ "help": "Post index (1 = most recent)"
+ }
+ ],
+ "columns": [
+ "status",
+ "user",
+ "post"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.instagram.com"
+ },
+ {
+ "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const idx = ${{ args.index }} - 1;\n const headers = { 'X-IG-App-ID': '936619743392459' };\n const opts = { credentials: 'include', headers };\n\n const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts);\n if (!r1.ok) throw new Error('User not found: ' + username);\n const userId = (await r1.json())?.data?.user?.id;\n\n const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts);\n const posts = (await r2.json())?.items || [];\n if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found');\n const pk = posts[idx].pk;\n const caption = (posts[idx].caption?.text || '').substring(0, 60);\n\n const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';\n const r3 = await fetch('https://www.instagram.com/api/v1/web/save/' + pk + '/save/', {\n method: 'POST', credentials: 'include',\n headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' },\n });\n if (!r3.ok) throw new Error('Failed to save: HTTP ' + r3.status);\n return [{ status: 'Saved', user: username, post: caption || '(post #' + (idx+1) + ')' }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "instagram/save.yaml"
+ },
+ {
+ "site": "instagram",
+ "name": "saved",
+ "description": "Get your saved Instagram posts",
+ "domain": "www.instagram.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "Number of saved posts"
+ }
+ ],
+ "columns": [
+ "index",
+ "user",
+ "caption",
+ "likes",
+ "comments",
+ "type"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.instagram.com"
+ },
+ {
+ "evaluate": "(async () => {\n const limit = ${{ args.limit }};\n const res = await fetch(\n 'https://www.instagram.com/api/v1/feed/saved/posts/',\n {\n credentials: 'include',\n headers: { 'X-IG-App-ID': '936619743392459' }\n }\n );\n if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram');\n const data = await res.json();\n return (data?.items || []).slice(0, limit).map((item, i) => {\n const m = item?.media;\n return {\n index: i + 1,\n user: m?.user?.username || '',\n caption: (m?.caption?.text || '').replace(/\\n/g, ' ').substring(0, 100),\n likes: m?.like_count ?? 0,\n comments: m?.comment_count ?? 0,\n type: m?.media_type === 1 ? 'photo' : m?.media_type === 2 ? 'video' : 'carousel',\n };\n });\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "instagram/saved.yaml"
+ },
+ {
+ "site": "instagram",
+ "name": "search",
+ "description": "Search Instagram users",
+ "domain": "www.instagram.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "query",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Search query"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 10,
+ "required": false,
+ "positional": false,
+ "help": "Number of results"
+ }
+ ],
+ "columns": [
+ "rank",
+ "username",
+ "name",
+ "verified",
+ "private",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.instagram.com"
+ },
+ {
+ "evaluate": "(async () => {\n const query = ${{ args.query | json }};\n const limit = ${{ args.limit }};\n const res = await fetch(\n 'https://www.instagram.com/web/search/topsearch/?query=' + encodeURIComponent(query) + '&context=user',\n {\n credentials: 'include',\n headers: { 'X-IG-App-ID': '936619743392459' }\n }\n );\n if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram');\n const data = await res.json();\n const users = (data?.users || []).slice(0, limit);\n return users.map((item, i) => ({\n rank: i + 1,\n username: item.user?.username || '',\n name: item.user?.full_name || '',\n verified: item.user?.is_verified ? 'Yes' : 'No',\n private: item.user?.is_private ? 'Yes' : 'No',\n url: 'https://www.instagram.com/' + (item.user?.username || ''),\n }));\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "instagram/search.yaml"
+ },
+ {
+ "site": "instagram",
+ "name": "unfollow",
+ "description": "Unfollow an Instagram user",
+ "domain": "www.instagram.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Instagram username to unfollow"
+ }
+ ],
+ "columns": [
+ "status",
+ "username"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.instagram.com"
+ },
+ {
+ "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const headers = { 'X-IG-App-ID': '936619743392459' };\n const opts = { credentials: 'include', headers };\n\n const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts);\n if (!r1.ok) throw new Error('User not found: ' + username);\n const d1 = await r1.json();\n const userId = d1?.data?.user?.id;\n if (!userId) throw new Error('User not found: ' + username);\n\n const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';\n const r2 = await fetch('https://www.instagram.com/api/v1/friendships/destroy/' + userId + '/', {\n method: 'POST',\n credentials: 'include',\n headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' },\n });\n if (!r2.ok) throw new Error('Failed to unfollow: HTTP ' + r2.status);\n return [{ status: 'Unfollowed', username }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "instagram/unfollow.yaml"
+ },
+ {
+ "site": "instagram",
+ "name": "unlike",
+ "description": "Unlike an Instagram post",
+ "domain": "www.instagram.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Username of the post author"
+ },
+ {
+ "name": "index",
+ "type": "int",
+ "default": 1,
+ "required": false,
+ "positional": false,
+ "help": "Post index (1 = most recent)"
+ }
+ ],
+ "columns": [
+ "status",
+ "user",
+ "post"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.instagram.com"
+ },
+ {
+ "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const idx = ${{ args.index }} - 1;\n const headers = { 'X-IG-App-ID': '936619743392459' };\n const opts = { credentials: 'include', headers };\n\n const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts);\n if (!r1.ok) throw new Error('User not found: ' + username);\n const userId = (await r1.json())?.data?.user?.id;\n\n const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts);\n const posts = (await r2.json())?.items || [];\n if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found');\n const pk = posts[idx].pk;\n const caption = (posts[idx].caption?.text || '').substring(0, 60);\n\n const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';\n const r3 = await fetch('https://www.instagram.com/api/v1/web/likes/' + pk + '/unlike/', {\n method: 'POST', credentials: 'include',\n headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' },\n });\n if (!r3.ok) throw new Error('Failed to unlike: HTTP ' + r3.status);\n return [{ status: 'Unliked', user: username, post: caption || '(post #' + (idx+1) + ')' }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "instagram/unlike.yaml"
+ },
+ {
+ "site": "instagram",
+ "name": "unsave",
+ "description": "Unsave (remove bookmark) an Instagram post",
+ "domain": "www.instagram.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Username of the post author"
+ },
+ {
+ "name": "index",
+ "type": "int",
+ "default": 1,
+ "required": false,
+ "positional": false,
+ "help": "Post index (1 = most recent)"
+ }
+ ],
+ "columns": [
+ "status",
+ "user",
+ "post"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.instagram.com"
+ },
+ {
+ "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const idx = ${{ args.index }} - 1;\n const headers = { 'X-IG-App-ID': '936619743392459' };\n const opts = { credentials: 'include', headers };\n\n const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts);\n if (!r1.ok) throw new Error('User not found: ' + username);\n const userId = (await r1.json())?.data?.user?.id;\n\n const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts);\n const posts = (await r2.json())?.items || [];\n if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found');\n const pk = posts[idx].pk;\n const caption = (posts[idx].caption?.text || '').substring(0, 60);\n\n const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';\n const r3 = await fetch('https://www.instagram.com/api/v1/web/save/' + pk + '/unsave/', {\n method: 'POST', credentials: 'include',\n headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' },\n });\n if (!r3.ok) throw new Error('Failed to unsave: HTTP ' + r3.status);\n return [{ status: 'Unsaved', user: username, post: caption || '(post #' + (idx+1) + ')' }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "instagram/unsave.yaml"
+ },
+ {
+ "site": "instagram",
+ "name": "user",
+ "description": "Get recent posts from an Instagram user",
+ "domain": "www.instagram.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Instagram username"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 12,
+ "required": false,
+ "positional": false,
+ "help": "Number of posts"
+ }
+ ],
+ "columns": [
+ "index",
+ "caption",
+ "likes",
+ "comments",
+ "type",
+ "date"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.instagram.com"
+ },
+ {
+ "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const limit = ${{ args.limit }};\n const headers = { 'X-IG-App-ID': '936619743392459' };\n const opts = { credentials: 'include', headers };\n\n // Get user ID first\n const r1 = await fetch(\n 'https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username),\n opts\n );\n if (!r1.ok) throw new Error('HTTP ' + r1.status + ' - make sure you are logged in to Instagram');\n const d1 = await r1.json();\n const userId = d1?.data?.user?.id;\n if (!userId) throw new Error('User not found: ' + username);\n\n // Get user feed\n const r2 = await fetch(\n 'https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + limit,\n opts\n );\n if (!r2.ok) throw new Error('Failed to fetch user feed: HTTP ' + r2.status);\n const d2 = await r2.json();\n return (d2?.items || []).slice(0, limit).map((p, i) => ({\n index: i + 1,\n caption: (p.caption?.text || '').replace(/\\n/g, ' ').substring(0, 100),\n likes: p.like_count ?? 0,\n comments: p.comment_count ?? 0,\n type: p.media_type === 1 ? 'photo' : p.media_type === 2 ? 'video' : 'carousel',\n date: p.taken_at ? new Date(p.taken_at * 1000).toLocaleDateString() : '',\n }));\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "instagram/user.yaml"
+ },
+ {
+ "site": "jike",
+ "name": "post",
+ "description": "即刻帖子详情及评论",
+ "domain": "m.okjike.com",
+ "strategy": "cookie",
+ "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": "(() => {\n try {\n const el = document.querySelector('script[type=\"application/json\"]');\n if (!el) return [];\n const data = JSON.parse(el.textContent);\n const pageProps = data?.props?.pageProps || {};\n const post = pageProps.post || {};\n const comments = pageProps.comments || [];\n\n const result = [{\n type: 'post',\n author: post.user?.screenName || '',\n content: post.content || '',\n likes: post.likeCount || 0,\n time: post.createdAt || '',\n }];\n\n for (const c of comments) {\n result.push({\n type: 'comment',\n author: c.user?.screenName || '',\n content: (c.content || '').replace(/\\n/g, ' '),\n likes: c.likeCount || 0,\n time: c.createdAt || '',\n });\n }\n\n return result;\n } catch (e) {\n return [];\n }\n})()\n"
+ },
+ {
+ "map": {
+ "type": "${{ item.type }}",
+ "author": "${{ item.author }}",
+ "content": "${{ item.content }}",
+ "likes": "${{ item.likes }}",
+ "time": "${{ item.time }}"
+ }
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "jike/post.yaml"
+ },
+ {
+ "site": "jike",
+ "name": "topic",
+ "description": "即刻话题/圈子帖子",
+ "domain": "m.okjike.com",
+ "strategy": "cookie",
+ "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,
+ "required": false,
+ "positional": false,
+ "help": "Number of posts"
+ }
+ ],
+ "columns": [
+ "content",
+ "author",
+ "likes",
+ "comments",
+ "time",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://m.okjike.com/topics/${{ args.id }}"
+ },
+ {
+ "evaluate": "(() => {\n try {\n const el = document.querySelector('script[type=\"application/json\"]');\n if (!el) return [];\n const data = JSON.parse(el.textContent);\n const pageProps = data?.props?.pageProps || {};\n const posts = pageProps.posts || [];\n return posts.map(p => ({\n content: (p.content || '').replace(/\\n/g, ' ').slice(0, 80),\n author: p.user?.screenName || '',\n likes: p.likeCount || 0,\n comments: p.commentCount || 0,\n time: p.actionTime || p.createdAt || '',\n id: p.id || '',\n }));\n } catch (e) {\n return [];\n }\n})()\n"
+ },
+ {
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "jike/topic.yaml"
+ },
+ {
+ "site": "jike",
+ "name": "user",
+ "description": "即刻用户动态",
+ "domain": "m.okjike.com",
+ "strategy": "cookie",
+ "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,
+ "required": false,
+ "positional": false,
+ "help": "Number of posts"
+ }
+ ],
+ "columns": [
+ "content",
+ "type",
+ "likes",
+ "comments",
+ "time",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://m.okjike.com/users/${{ args.username }}"
+ },
+ {
+ "evaluate": "(() => {\n try {\n const el = document.querySelector('script[type=\"application/json\"]');\n if (!el) return [];\n const data = JSON.parse(el.textContent);\n const posts = data?.props?.pageProps?.posts || [];\n return posts.map(p => ({\n content: (p.content || '').replace(/\\n/g, ' ').slice(0, 80),\n type: p.type === 'ORIGINAL_POST' ? 'post' : p.type === 'REPOST' ? 'repost' : p.type || '',\n likes: p.likeCount || 0,\n comments: p.commentCount || 0,\n time: p.actionTime || p.createdAt || '',\n id: p.id || '',\n }));\n } catch (e) {\n return [];\n }\n})()\n"
+ },
+ {
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "jike/user.yaml"
+ },
+ {
+ "site": "jimeng",
+ "name": "generate",
+ "description": "即梦AI 文生图 — 输入 prompt 生成图片",
+ "domain": "jimeng.jianying.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "prompt",
+ "type": "string",
+ "required": true,
+ "positional": true,
+ "help": "图片描述 prompt"
+ },
+ {
+ "name": "model",
+ "type": "string",
+ "default": "high_aes_general_v50",
+ "required": false,
+ "positional": false,
+ "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,
+ "required": false,
+ "positional": false,
+ "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 () => {\n const prompt = ${{ args.prompt | json }};\n const waitSec = ${{ args.wait }};\n \n // Step 1: Count existing images before generation\n const beforeImgs = document.querySelectorAll('img[src*=\"dreamina-sign\"], img[src*=\"tb4s082cfz\"]').length;\n \n // Step 2: Clear and set prompt\n const editors = document.querySelectorAll('[contenteditable=\"true\"]');\n const editor = editors[0];\n if (!editor) return [{ status: 'failed', prompt: prompt, image_count: 0, image_urls: 'Editor not found' }];\n \n editor.focus();\n await new Promise(r => setTimeout(r, 200));\n document.execCommand('selectAll');\n await new Promise(r => setTimeout(r, 100));\n document.execCommand('delete');\n await new Promise(r => setTimeout(r, 200));\n document.execCommand('insertText', false, prompt);\n await new Promise(r => setTimeout(r, 500));\n \n // Step 3: Click generate\n const btn = document.querySelector('.lv-btn.lv-btn-primary[class*=\"circle\"]');\n if (!btn) return [{ status: 'failed', prompt: prompt, image_count: 0, image_urls: 'Generate button not found' }];\n btn.click();\n \n // Step 4: Wait for new images to appear\n let newImgs = [];\n for (let i = 0; i < waitSec; i++) {\n await new Promise(r => setTimeout(r, 1000));\n const allImgs = document.querySelectorAll('img[src*=\"dreamina-sign\"], img[src*=\"tb4s082cfz\"]');\n if (allImgs.length > beforeImgs) {\n // New images appeared — generation complete\n newImgs = Array.from(allImgs).slice(0, allImgs.length - beforeImgs);\n break;\n }\n }\n \n if (newImgs.length === 0) {\n return [{ status: 'timeout', prompt: prompt, image_count: 0, image_urls: 'Generation may still be in progress' }];\n }\n \n // Step 5: Extract image URLs (use thumbnail URLs which are accessible)\n const urls = newImgs.map(img => img.src);\n \n return [{ \n status: 'success', \n prompt: prompt.substring(0, 80), \n image_count: urls.length, \n image_urls: urls.join('\\n')\n }];\n})()\n"
+ },
+ {
+ "map": {
+ "status": "${{ item.status }}",
+ "prompt": "${{ item.prompt }}",
+ "image_count": "${{ item.image_count }}",
+ "image_urls": "${{ item.image_urls }}"
+ }
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "jimeng/generate.yaml"
+ },
+ {
+ "site": "jimeng",
+ "name": "history",
+ "description": "即梦AI 查看最近生成的作品",
+ "domain": "jimeng.jianying.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 5,
+ "required": false,
+ "positional": false,
+ "help": ""
+ }
+ ],
+ "columns": [
+ "prompt",
+ "model",
+ "status",
+ "image_url",
+ "created_at"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://jimeng.jianying.com/ai-tool/generate?type=image&workspace=0"
+ },
+ {
+ "evaluate": "(async () => {\n const limit = ${{ args.limit }};\n 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', {\n method: 'POST',\n credentials: 'include',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ cursor: '', count: limit, need_page_item: true, need_aigc_data: true, aigc_mode_list: ['workbench'] })\n });\n const data = await res.json();\n const items = data?.data?.history_list || [];\n return items.slice(0, limit).map(item => {\n const params = item.aigc_image_params?.text2image_params || {};\n const images = item.image?.large_images || [];\n return {\n prompt: params.prompt || item.common_attr?.title || 'N/A',\n model: params.model_config?.model_name || 'unknown',\n status: item.common_attr?.status === 102 ? 'completed' : 'pending',\n image_url: images[0]?.image_url || '',\n created_at: new Date((item.common_attr?.create_time || 0) * 1000).toLocaleString('zh-CN'),\n };\n });\n})()\n"
+ },
+ {
+ "map": {
+ "prompt": "${{ item.prompt }}",
+ "model": "${{ item.model }}",
+ "status": "${{ item.status }}",
+ "image_url": "${{ item.image_url }}",
+ "created_at": "${{ item.created_at }}"
+ }
+ },
+ {
+ "limit": "${{ args.limit }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "jimeng/history.yaml"
+ },
+ {
+ "site": "linux-do",
+ "name": "categories",
+ "description": "linux.do 分类列表",
+ "domain": "linux.do",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "subcategories",
+ "type": "boolean",
+ "default": false,
+ "required": false,
+ "positional": false,
+ "help": "Include subcategories"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "Number of categories"
+ }
+ ],
+ "columns": [
+ "name",
+ "slug",
+ "id",
+ "topics",
+ "description"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://linux.do"
+ },
+ {
+ "evaluate": "(async () => {\n const res = await fetch('/categories.json', { credentials: 'include' });\n if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');\n let data;\n try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }\n const cats = data?.category_list?.categories || [];\n const showSub = ${{ args.subcategories }};\n const results = [];\n const limit = ${{ args.limit }};\n for (const c of cats.slice(0, ${{ args.limit }})) {\n results.push({\n name: c.name,\n slug: c.slug,\n id: c.id,\n topics: c.topic_count,\n description: (c.description_text || '').slice(0, 80),\n });\n if (results.length >= limit) break;\n if (showSub && c.subcategory_ids && c.subcategory_ids.length > 0) {\n const subRes = await fetch('/categories.json?parent_category_id=' + c.id, { credentials: 'include' });\n if (subRes.ok) {\n let subData;\n try { subData = await subRes.json(); } catch { continue; }\n const subCats = subData?.category_list?.categories || [];\n for (const sc of subCats) {\n results.push({\n name: c.name + ' / ' + sc.name,\n slug: sc.slug,\n id: sc.id,\n topics: sc.topic_count,\n description: (sc.description_text || '').slice(0, 80),\n });\n if (results.length >= limit) break;\n }\n }\n }\n if (results.length >= limit) break;\n }\n return results;\n})()\n"
+ },
+ {
+ "map": {
+ "name": "${{ item.name }}",
+ "slug": "${{ item.slug }}",
+ "id": "${{ item.id }}",
+ "topics": "${{ item.topics }}",
+ "description": "${{ item.description }}"
+ }
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "linux-do/categories.yaml"
+ },
+ {
+ "site": "linux-do",
+ "name": "search",
+ "description": "搜索 linux.do",
+ "domain": "linux.do",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "query",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Search query"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "Number of results"
+ }
+ ],
+ "columns": [
+ "rank",
+ "title",
+ "views",
+ "likes",
+ "replies",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://linux.do"
+ },
+ {
+ "evaluate": "(async () => {\n const keyword = ${{ args.query | json }};\n const res = await fetch('/search.json?q=' + encodeURIComponent(keyword), { credentials: 'include' });\n if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');\n let data;\n try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }\n const topics = data?.topics || [];\n return topics.slice(0, ${{ args.limit }}).map(t => ({\n title: t.title,\n views: t.views,\n likes: t.like_count,\n replies: (t.posts_count || 1) - 1,\n url: 'https://linux.do/t/topic/' + t.id,\n }));\n})()\n"
+ },
+ {
+ "map": {
+ "rank": "${{ index + 1 }}",
+ "title": "${{ item.title }}",
+ "views": "${{ item.views }}",
+ "likes": "${{ item.likes }}",
+ "replies": "${{ item.replies }}",
+ "url": "${{ item.url }}"
+ }
+ },
+ {
+ "limit": "${{ args.limit }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "linux-do/search.yaml"
+ },
+ {
+ "site": "linux-do",
+ "name": "tags",
+ "description": "linux.do 标签列表",
+ "domain": "linux.do",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 30,
+ "required": false,
+ "positional": false,
+ "help": "Number of tags"
+ }
+ ],
+ "columns": [
+ "rank",
+ "name",
+ "count",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://linux.do"
+ },
+ {
+ "evaluate": "(async () => {\n const res = await fetch('/tags.json', { credentials: 'include' });\n if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');\n let data;\n try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }\n let tags = data?.tags || [];\n tags.sort((a, b) => (b.count || 0) - (a.count || 0));\n return tags.slice(0, ${{ args.limit }}).map(t => ({\n id: t.id,\n name: t.name || t.id,\n slug: t.slug,\n count: t.count || 0,\n }));\n})()\n"
+ },
+ {
+ "map": {
+ "rank": "${{ index + 1 }}",
+ "name": "${{ item.name }}",
+ "count": "${{ item.count }}",
+ "slug": "${{ item.slug }}",
+ "id": "${{ item.id }}",
+ "url": "https://linux.do/tag/${{ item.slug }}"
+ }
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "linux-do/tags.yaml"
+ },
+ {
+ "site": "linux-do",
+ "name": "topic",
+ "description": "linux.do 帖子首页摘要和回复(首屏)",
+ "domain": "linux.do",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "id",
+ "type": "int",
+ "required": true,
+ "positional": true,
+ "help": "Topic ID"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "Number of posts"
+ }
+ ],
+ "columns": [
+ "author",
+ "content",
+ "likes",
+ "created_at"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://linux.do"
+ },
+ {
+ "evaluate": "(async () => {\n const toLocalTime = (utcStr) => {\n if (!utcStr) return '';\n const date = new Date(utcStr);\n return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();\n };\n const res = await fetch('/t/${{ args.id }}.json', { credentials: 'include' });\n if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');\n let data;\n try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }\n const strip = (html) => (html || '')\n .replace(/
/gi, ' ')\n .replace(/<\\/(p|div|li|blockquote|h[1-6])>/gi, ' ')\n .replace(/<[^>]+>/g, '')\n .replace(/ /g, ' ')\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/"/g, '\"')\n .replace(/(?:(\\d+)|x([0-9a-fA-F]+));/g, (_, dec, hex) => {\n try { return String.fromCodePoint(dec !== undefined ? Number(dec) : parseInt(hex, 16)); } catch { return ''; }\n })\n .replace(/\\s+/g, ' ')\n .trim();\n const posts = data?.post_stream?.posts || [];\n return posts.slice(0, ${{ args.limit }}).map(p => ({\n author: p.username,\n content: strip(p.cooked).slice(0, 200),\n likes: p.like_count,\n created_at: toLocalTime(p.created_at),\n }));\n})()\n"
+ },
+ {
+ "map": {
+ "author": "${{ item.author }}",
+ "content": "${{ item.content }}",
+ "likes": "${{ item.likes }}",
+ "created_at": "${{ item.created_at }}"
+ }
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "linux-do/topic.yaml"
+ },
+ {
+ "site": "linux-do",
+ "name": "user-posts",
+ "description": "linux.do 用户的帖子",
+ "domain": "linux.do",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Username"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "Number of posts"
+ }
+ ],
+ "columns": [
+ "index",
+ "topic_user",
+ "topic",
+ "reply",
+ "time",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://linux.do"
+ },
+ {
+ "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const toLocalTime = (utcStr) => {\n if (!utcStr) return '';\n const date = new Date(utcStr);\n return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();\n };\n const strip = (html) => (html || '')\n .replace(/
/gi, ' ')\n .replace(/<\\/(p|div|li|blockquote|h[1-6])>/gi, ' ')\n .replace(/<[^>]+>/g, '')\n .replace(/ /g, ' ')\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/"/g, '\"')\n .replace(/(?:(\\d+)|x([0-9a-fA-F]+));/g, (_, dec, hex) => {\n try { return String.fromCodePoint(dec !== undefined ? Number(dec) : parseInt(hex, 16)); } catch { return ''; }\n })\n .replace(/\\s+/g, ' ')\n .trim();\n const limit = ${{ args.limit | default(20) }};\n const res = await fetch('/user_actions.json?username=' + encodeURIComponent(username) + '&filter=5&offset=0&limit=' + limit, { credentials: 'include' });\n if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');\n let data;\n try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }\n const actions = data?.user_actions || [];\n return actions.slice(0, limit).map(a => ({\n author: a.acting_username || a.username || '',\n title: a.title || '',\n content: strip(a.excerpt).slice(0, 200),\n created_at: toLocalTime(a.created_at),\n url: 'https://linux.do/t/topic/' + a.topic_id + '/' + a.post_number,\n }));\n})()\n"
+ },
+ {
+ "map": {
+ "index": "${{ index + 1 }}",
+ "topic_user": "${{ item.author }}",
+ "topic": "${{ item.title }}",
+ "reply": "${{ item.content }}",
+ "time": "${{ item.created_at }}",
+ "url": "${{ item.url }}"
+ }
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "linux-do/user-posts.yaml"
+ },
+ {
+ "site": "linux-do",
+ "name": "user-topics",
+ "description": "linux.do 用户创建的话题",
+ "domain": "linux.do",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Username"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "Number of topics"
+ }
+ ],
+ "columns": [
+ "rank",
+ "title",
+ "replies",
+ "created_at",
+ "likes",
+ "views",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://linux.do"
+ },
+ {
+ "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const toLocalTime = (utcStr) => {\n if (!utcStr) return '';\n const date = new Date(utcStr);\n return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();\n };\n const res = await fetch('/topics/created-by/' + encodeURIComponent(username) + '.json', { credentials: 'include' });\n if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');\n let data;\n try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }\n const topics = data?.topic_list?.topics || [];\n return topics.slice(0, ${{ args.limit }}).map(t => ({\n title: t.fancy_title || t.title || '',\n replies: t.posts_count || 0,\n created_at: toLocalTime(t.created_at),\n likes: t.like_count || 0,\n views: t.views || 0,\n url: 'https://linux.do/t/topic/' + t.id,\n }));\n})()\n"
+ },
+ {
+ "map": {
+ "rank": "${{ index + 1 }}",
+ "title": "${{ item.title }}",
+ "replies": "${{ item.replies }}",
+ "created_at": "${{ item.created_at }}",
+ "likes": "${{ item.likes }}",
+ "views": "${{ item.views }}",
+ "url": "${{ item.url }}"
+ }
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "linux-do/user-topics.yaml"
+ },
+ {
+ "site": "lobsters",
+ "name": "active",
+ "description": "Lobste.rs most active discussions",
+ "domain": "lobste.rs",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "lobsters/active.yaml"
+ },
+ {
+ "site": "lobsters",
+ "name": "hot",
+ "description": "Lobste.rs hottest stories",
+ "domain": "lobste.rs",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "lobsters/hot.yaml"
+ },
+ {
+ "site": "lobsters",
+ "name": "newest",
+ "description": "Lobste.rs newest stories",
+ "domain": "lobste.rs",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "lobsters/newest.yaml"
+ },
+ {
+ "site": "lobsters",
+ "name": "tag",
+ "description": "Lobste.rs stories by tag",
+ "domain": "lobste.rs",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "tag",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Tag name (e.g. programming, rust, security, ai)"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "lobsters/tag.yaml"
+ },
+ {
+ "site": "pixiv",
+ "name": "detail",
+ "description": "View illustration details (tags, stats, URLs)",
+ "domain": "www.pixiv.net",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "id",
+ "type": "str",
+ "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 () => {\n const id = ${{ args.id | json }};\n const res = await fetch(\n 'https://www.pixiv.net/ajax/illust/' + id,\n { credentials: 'include' }\n );\n if (!res.ok) {\n if (res.status === 401 || res.status === 403) throw new Error('Authentication required — please log in to Pixiv in Chrome');\n if (res.status === 404) throw new Error('Illustration not found: ' + id);\n throw new Error('Pixiv request failed (HTTP ' + res.status + ')');\n }\n const data = await res.json();\n const b = data?.body;\n if (!b) throw new Error('Illustration not found');\n return [{\n illust_id: b.illustId,\n title: b.illustTitle,\n author: b.userName,\n user_id: b.userId,\n type: b.illustType === 0 ? 'illust' : b.illustType === 1 ? 'manga' : b.illustType === 2 ? 'ugoira' : String(b.illustType),\n pages: b.pageCount,\n bookmarks: b.bookmarkCount,\n likes: b.likeCount,\n views: b.viewCount,\n tags: (b.tags?.tags || []).map(t => t.tag).join(', '),\n created: b.createDate?.split('T')[0] || '',\n url: 'https://www.pixiv.net/artworks/' + b.illustId\n }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "pixiv/detail.yaml"
+ },
+ {
+ "site": "pixiv",
+ "name": "ranking",
+ "description": "Pixiv illustration rankings (daily/weekly/monthly)",
+ "domain": "www.pixiv.net",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "mode",
+ "type": "str",
+ "default": "daily",
+ "required": false,
+ "positional": false,
+ "help": "Ranking mode",
+ "choices": [
+ "daily",
+ "weekly",
+ "monthly",
+ "rookie",
+ "original",
+ "male",
+ "female",
+ "daily_r18",
+ "weekly_r18"
+ ]
+ },
+ {
+ "name": "page",
+ "type": "int",
+ "default": 1,
+ "required": false,
+ "positional": false,
+ "help": "Page number"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "Number of results"
+ }
+ ],
+ "columns": [
+ "rank",
+ "title",
+ "author",
+ "illust_id",
+ "pages",
+ "bookmarks"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.pixiv.net"
+ },
+ {
+ "evaluate": "(async () => {\n const mode = ${{ args.mode | json }};\n const page = ${{ args.page | json }};\n const limit = ${{ args.limit | json }};\n const res = await fetch(\n 'https://www.pixiv.net/ranking.php?mode=' + mode + '&p=' + page + '&format=json',\n { credentials: 'include' }\n );\n if (!res.ok) {\n if (res.status === 401 || res.status === 403) throw new Error('Authentication required — please log in to Pixiv in Chrome');\n throw new Error('Pixiv request failed (HTTP ' + res.status + ')');\n }\n const data = await res.json();\n const items = (data?.contents || []).slice(0, limit);\n return items.map((item, i) => ({\n rank: item.rank,\n title: item.title,\n author: item.user_name,\n user_id: item.user_id,\n illust_id: item.illust_id,\n pages: item.illust_page_count,\n bookmarks: item.illust_bookmark_count,\n url: 'https://www.pixiv.net/artworks/' + item.illust_id\n }));\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "pixiv/ranking.yaml"
+ },
+ {
+ "site": "pixiv",
+ "name": "user",
+ "description": "View Pixiv artist profile",
+ "domain": "www.pixiv.net",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "uid",
+ "type": "str",
+ "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 () => {\n const uid = ${{ args.uid | json }};\n const res = await fetch(\n 'https://www.pixiv.net/ajax/user/' + uid + '?full=1',\n { credentials: 'include' }\n );\n if (!res.ok) {\n if (res.status === 401 || res.status === 403) throw new Error('Authentication required — please log in to Pixiv in Chrome');\n if (res.status === 404) throw new Error('User not found: ' + uid);\n throw new Error('Pixiv request failed (HTTP ' + res.status + ')');\n }\n const data = await res.json();\n const b = data?.body;\n if (!b) throw new Error('User not found');\n return [{\n user_id: uid,\n name: b.name,\n premium: b.premium ? 'Yes' : 'No',\n following: b.following,\n illusts: typeof b.illusts === 'object' ? Object.keys(b.illusts).length : (b.illusts || 0),\n manga: typeof b.manga === 'object' ? Object.keys(b.manga).length : (b.manga || 0),\n novels: typeof b.novels === 'object' ? Object.keys(b.novels).length : (b.novels || 0),\n comment: (b.comment || '').slice(0, 80),\n url: 'https://www.pixiv.net/users/' + uid\n }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "pixiv/user.yaml"
+ },
+ {
+ "site": "reddit",
+ "name": "frontpage",
+ "description": "Reddit Frontpage / r/all",
+ "domain": "reddit.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 15,
+ "required": false,
+ "positional": false,
+ "help": ""
+ }
+ ],
+ "columns": [
+ "title",
+ "subreddit",
+ "author",
+ "upvotes",
+ "comments",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.reddit.com"
+ },
+ {
+ "evaluate": "(async () => {\n const res = await fetch('/r/all.json?limit=${{ args.limit }}', { credentials: 'include' });\n const j = await res.json();\n return j?.data?.children || [];\n})()\n"
+ },
+ {
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "reddit/frontpage.yaml"
+ },
+ {
+ "site": "reddit",
+ "name": "hot",
+ "description": "Reddit 热门帖子",
+ "domain": "www.reddit.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "subreddit",
+ "type": "str",
+ "default": "",
+ "required": false,
+ "positional": false,
+ "help": "Subreddit name (e.g. programming). Empty for frontpage"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "Number of posts"
+ }
+ ],
+ "columns": [
+ "rank",
+ "title",
+ "subreddit",
+ "score",
+ "comments"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.reddit.com"
+ },
+ {
+ "evaluate": "(async () => {\n const sub = ${{ args.subreddit | json }};\n const path = sub ? '/r/' + sub + '/hot.json' : '/hot.json';\n const limit = ${{ args.limit }};\n const res = await fetch(path + '?limit=' + limit + '&raw_json=1', {\n credentials: 'include'\n });\n const d = await res.json();\n return (d?.data?.children || []).map(c => ({\n title: c.data.title,\n subreddit: c.data.subreddit_name_prefixed,\n score: c.data.score,\n comments: c.data.num_comments,\n author: c.data.author,\n url: 'https://www.reddit.com' + c.data.permalink,\n }));\n})()\n"
+ },
+ {
+ "map": {
+ "rank": "${{ index + 1 }}",
+ "title": "${{ item.title }}",
+ "subreddit": "${{ item.subreddit }}",
+ "score": "${{ item.score }}",
+ "comments": "${{ item.comments }}"
+ }
+ },
+ {
+ "limit": "${{ args.limit }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "reddit/hot.yaml"
+ },
+ {
+ "site": "reddit",
+ "name": "popular",
+ "description": "Reddit Popular posts (/r/popular)",
+ "domain": "reddit.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": ""
+ }
+ ],
+ "columns": [
+ "rank",
+ "title",
+ "subreddit",
+ "score",
+ "comments",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.reddit.com"
+ },
+ {
+ "evaluate": "(async () => {\n const limit = ${{ args.limit }};\n const res = await fetch('/r/popular.json?limit=' + limit + '&raw_json=1', {\n credentials: 'include'\n });\n const d = await res.json();\n return (d?.data?.children || []).map(c => ({\n title: c.data.title,\n subreddit: c.data.subreddit_name_prefixed,\n score: c.data.score,\n comments: c.data.num_comments,\n author: c.data.author,\n url: 'https://www.reddit.com' + c.data.permalink,\n }));\n})()\n"
+ },
+ {
+ "map": {
+ "rank": "${{ index + 1 }}",
+ "title": "${{ item.title }}",
+ "subreddit": "${{ item.subreddit }}",
+ "score": "${{ item.score }}",
+ "comments": "${{ item.comments }}",
+ "url": "${{ item.url }}"
+ }
+ },
+ {
+ "limit": "${{ args.limit }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "reddit/popular.yaml"
+ },
+ {
+ "site": "reddit",
+ "name": "search",
+ "description": "Search Reddit Posts",
+ "domain": "reddit.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "query",
+ "type": "string",
+ "required": true,
+ "positional": true,
+ "help": ""
+ },
+ {
+ "name": "subreddit",
+ "type": "string",
+ "default": "",
+ "required": false,
+ "positional": false,
+ "help": "Search within a specific subreddit"
+ },
+ {
+ "name": "sort",
+ "type": "string",
+ "default": "relevance",
+ "required": false,
+ "positional": false,
+ "help": "Sort order: relevance, hot, top, new, comments"
+ },
+ {
+ "name": "time",
+ "type": "string",
+ "default": "all",
+ "required": false,
+ "positional": false,
+ "help": "Time filter: hour, day, week, month, year, all"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 15,
+ "required": false,
+ "positional": false,
+ "help": ""
+ }
+ ],
+ "columns": [
+ "title",
+ "subreddit",
+ "author",
+ "score",
+ "comments",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.reddit.com"
+ },
+ {
+ "evaluate": "(async () => {\n const q = encodeURIComponent(${{ args.query | json }});\n const sub = ${{ args.subreddit | json }};\n const sort = ${{ args.sort | json }};\n const time = ${{ args.time | json }};\n const limit = ${{ args.limit }};\n const basePath = sub ? '/r/' + sub + '/search.json' : '/search.json';\n const params = 'q=' + q + '&sort=' + sort + '&t=' + time + '&limit=' + limit\n + '&restrict_sr=' + (sub ? 'on' : 'off') + '&raw_json=1';\n const res = await fetch(basePath + '?' + params, { credentials: 'include' });\n const d = await res.json();\n return (d?.data?.children || []).map(c => ({\n title: c.data.title,\n subreddit: c.data.subreddit_name_prefixed,\n author: c.data.author,\n score: c.data.score,\n comments: c.data.num_comments,\n url: 'https://www.reddit.com' + c.data.permalink,\n }));\n})()\n"
+ },
+ {
+ "map": {
+ "title": "${{ item.title }}",
+ "subreddit": "${{ item.subreddit }}",
+ "author": "${{ item.author }}",
+ "score": "${{ item.score }}",
+ "comments": "${{ item.comments }}",
+ "url": "${{ item.url }}"
+ }
+ },
+ {
+ "limit": "${{ args.limit }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "reddit/search.yaml"
+ },
+ {
+ "site": "reddit",
+ "name": "subreddit",
+ "description": "Get posts from a specific Subreddit",
+ "domain": "reddit.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "name",
+ "type": "string",
+ "required": true,
+ "positional": true,
+ "help": ""
+ },
+ {
+ "name": "sort",
+ "type": "string",
+ "default": "hot",
+ "required": false,
+ "positional": false,
+ "help": "Sorting method: hot, new, top, rising, controversial"
+ },
+ {
+ "name": "time",
+ "type": "string",
+ "default": "all",
+ "required": false,
+ "positional": false,
+ "help": "Time filter for top/controversial: hour, day, week, month, year, all"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 15,
+ "required": false,
+ "positional": false,
+ "help": ""
+ }
+ ],
+ "columns": [
+ "title",
+ "author",
+ "upvotes",
+ "comments",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.reddit.com"
+ },
+ {
+ "evaluate": "(async () => {\n let sub = ${{ args.name | json }};\n if (sub.startsWith('r/')) sub = sub.slice(2);\n const sort = ${{ args.sort | json }};\n const time = ${{ args.time | json }};\n const limit = ${{ args.limit }};\n let url = '/r/' + sub + '/' + sort + '.json?limit=' + limit + '&raw_json=1';\n if ((sort === 'top' || sort === 'controversial') && time) {\n url += '&t=' + time;\n }\n const res = await fetch(url, { credentials: 'include' });\n const j = await res.json();\n return j?.data?.children || [];\n})()\n"
+ },
+ {
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "reddit/subreddit.yaml"
+ },
+ {
+ "site": "reddit",
+ "name": "user",
+ "description": "View a Reddit user profile",
+ "domain": "reddit.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "string",
+ "required": true,
+ "positional": true,
+ "help": ""
+ }
+ ],
+ "columns": [
+ "field",
+ "value"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.reddit.com"
+ },
+ {
+ "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const name = username.startsWith('u/') ? username.slice(2) : username;\n const res = await fetch('/user/' + name + '/about.json?raw_json=1', {\n credentials: 'include'\n });\n const d = await res.json();\n const u = d?.data || d || {};\n const created = u.created_utc ? new Date(u.created_utc * 1000).toISOString().split('T')[0] : '-';\n return [\n { field: 'Username', value: 'u/' + (u.name || name) },\n { field: 'Post Karma', value: String(u.link_karma || 0) },\n { field: 'Comment Karma', value: String(u.comment_karma || 0) },\n { field: 'Total Karma', value: String(u.total_karma || (u.link_karma||0) + (u.comment_karma||0)) },\n { field: 'Account Created', value: created },\n { field: 'Gold', value: u.is_gold ? '⭐ Yes' : 'No' },\n { field: 'Verified', value: u.verified ? '✅ Yes' : 'No' },\n ];\n})()\n"
+ },
+ {
+ "map": {
+ "field": "${{ item.field }}",
+ "value": "${{ item.value }}"
+ }
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "reddit/user.yaml"
+ },
+ {
+ "site": "reddit",
+ "name": "user-comments",
+ "description": "View a Reddit user's comment history",
+ "domain": "reddit.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "string",
+ "required": true,
+ "positional": true,
+ "help": ""
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 15,
+ "required": false,
+ "positional": false,
+ "help": ""
+ }
+ ],
+ "columns": [
+ "subreddit",
+ "score",
+ "body",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.reddit.com"
+ },
+ {
+ "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const name = username.startsWith('u/') ? username.slice(2) : username;\n const limit = ${{ args.limit }};\n const res = await fetch('/user/' + name + '/comments.json?limit=' + limit + '&raw_json=1', {\n credentials: 'include'\n });\n const d = await res.json();\n return (d?.data?.children || []).map(c => {\n let body = c.data.body || '';\n if (body.length > 300) body = body.slice(0, 300) + '...';\n return {\n subreddit: c.data.subreddit_name_prefixed,\n score: c.data.score,\n body: body,\n url: 'https://www.reddit.com' + c.data.permalink,\n };\n });\n})()\n"
+ },
+ {
+ "map": {
+ "subreddit": "${{ item.subreddit }}",
+ "score": "${{ item.score }}",
+ "body": "${{ item.body }}",
+ "url": "${{ item.url }}"
+ }
+ },
+ {
+ "limit": "${{ args.limit }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "reddit/user-comments.yaml"
+ },
+ {
+ "site": "reddit",
+ "name": "user-posts",
+ "description": "View a Reddit user's submitted posts",
+ "domain": "reddit.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "string",
+ "required": true,
+ "positional": true,
+ "help": ""
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 15,
+ "required": false,
+ "positional": false,
+ "help": ""
+ }
+ ],
+ "columns": [
+ "title",
+ "subreddit",
+ "score",
+ "comments",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.reddit.com"
+ },
+ {
+ "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const name = username.startsWith('u/') ? username.slice(2) : username;\n const limit = ${{ args.limit }};\n const res = await fetch('/user/' + name + '/submitted.json?limit=' + limit + '&raw_json=1', {\n credentials: 'include'\n });\n const d = await res.json();\n return (d?.data?.children || []).map(c => ({\n title: c.data.title,\n subreddit: c.data.subreddit_name_prefixed,\n score: c.data.score,\n comments: c.data.num_comments,\n url: 'https://www.reddit.com' + c.data.permalink,\n }));\n})()\n"
+ },
+ {
+ "map": {
+ "title": "${{ item.title }}",
+ "subreddit": "${{ item.subreddit }}",
+ "score": "${{ item.score }}",
+ "comments": "${{ item.comments }}",
+ "url": "${{ item.url }}"
+ }
+ },
+ {
+ "limit": "${{ args.limit }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "reddit/user-posts.yaml"
+ },
+ {
+ "site": "stackoverflow",
+ "name": "bounties",
+ "description": "Active bounties on Stack Overflow",
+ "domain": "stackoverflow.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 10,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "stackoverflow/bounties.yaml"
+ },
+ {
+ "site": "stackoverflow",
+ "name": "hot",
+ "description": "Hot Stack Overflow questions",
+ "domain": "stackoverflow.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 10,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "stackoverflow/hot.yaml"
+ },
+ {
+ "site": "stackoverflow",
+ "name": "search",
+ "description": "Search Stack Overflow questions",
+ "domain": "stackoverflow.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "query",
+ "type": "string",
+ "required": true,
+ "positional": true,
+ "help": "Search query"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 10,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "stackoverflow/search.yaml"
+ },
+ {
+ "site": "stackoverflow",
+ "name": "unanswered",
+ "description": "Top voted unanswered questions on Stack Overflow",
+ "domain": "stackoverflow.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 10,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "stackoverflow/unanswered.yaml"
+ },
+ {
+ "site": "steam",
+ "name": "top-sellers",
+ "description": "Steam top selling games",
+ "domain": "store.steampowered.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 10,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "steam/top-sellers.yaml"
+ },
+ {
+ "site": "tiktok",
+ "name": "comment",
+ "description": "Comment on a TikTok video",
+ "domain": "www.tiktok.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "url",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "TikTok video URL"
+ },
+ {
+ "name": "text",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Comment text"
+ }
+ ],
+ "columns": [
+ "status",
+ "url",
+ "text"
+ ],
+ "pipeline": [
+ {
+ "navigate": {
+ "url": "${{ args.url }}",
+ "settleMs": 6000
+ }
+ },
+ {
+ "evaluate": "(async () => {\n const url = ${{ args.url | json }};\n const commentText = ${{ args.text | json }};\n const wait = (ms) => new Promise(r => setTimeout(r, ms));\n\n // Click comment icon to expand comment section\n const commentIcon = document.querySelector('[data-e2e=\"comment-icon\"]');\n if (commentIcon) {\n const cBtn = commentIcon.closest('button') || commentIcon.closest('[role=\"button\"]') || commentIcon;\n cBtn.click();\n await wait(3000);\n }\n\n // Count existing comments for verification\n const beforeCount = document.querySelectorAll('[data-e2e=\"comment-level-1\"]').length;\n\n // Find comment input\n const input = document.querySelector('[data-e2e=\"comment-input\"] [contenteditable=\"true\"]') ||\n document.querySelector('[contenteditable=\"true\"]');\n if (!input) throw new Error('Comment input not found - make sure you are logged in');\n\n input.focus();\n document.execCommand('insertText', false, commentText);\n await wait(1000);\n\n // Click post button\n const btns = Array.from(document.querySelectorAll('[data-e2e=\"comment-post\"], button'));\n const postBtn = btns.find(function(b) {\n var t = b.textContent.trim();\n return t === 'Post' || t === '发布' || t === '发送';\n });\n if (!postBtn) throw new Error('Post button not found');\n postBtn.click();\n await wait(3000);\n\n // Verify comment was posted by checking if comment count increased\n const afterCount = document.querySelectorAll('[data-e2e=\"comment-level-1\"]').length;\n const posted = afterCount > beforeCount;\n\n return [{ status: posted ? 'Commented' : 'Comment may have failed', url: url, text: commentText }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "tiktok/comment.yaml"
+ },
+ {
+ "site": "tiktok",
+ "name": "explore",
+ "description": "Get trending TikTok videos from explore page",
+ "domain": "www.tiktok.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "Number of videos"
+ }
+ ],
+ "columns": [
+ "rank",
+ "author",
+ "views",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": {
+ "url": "https://www.tiktok.com/explore",
+ "settleMs": 5000
+ }
+ },
+ {
+ "evaluate": "(() => {\n const limit = ${{ args.limit }};\n const links = Array.from(document.querySelectorAll('a[href*=\"/video/\"]'));\n const seen = new Set();\n const results = [];\n for (const a of links) {\n const href = a.href;\n if (seen.has(href)) continue;\n seen.add(href);\n const match = href.match(/@([^/]+)\\/video\\/(\\d+)/);\n results.push({\n rank: results.length + 1,\n author: match ? match[1] : '',\n views: a.textContent.trim() || '-',\n url: href,\n });\n if (results.length >= limit) break;\n }\n return results;\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "tiktok/explore.yaml"
+ },
+ {
+ "site": "tiktok",
+ "name": "follow",
+ "description": "Follow a TikTok user",
+ "domain": "www.tiktok.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "TikTok username (without @)"
+ }
+ ],
+ "columns": [
+ "status",
+ "username"
+ ],
+ "pipeline": [
+ {
+ "navigate": {
+ "url": "https://www.tiktok.com/@${{ args.username }}",
+ "settleMs": 6000
+ }
+ },
+ {
+ "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const buttons = Array.from(document.querySelectorAll('button, [role=\"button\"]'));\n const followBtn = buttons.find(function(b) {\n var text = b.textContent.trim();\n return text === 'Follow' || text === '关注';\n });\n if (!followBtn) {\n var isFollowing = buttons.some(function(b) {\n var t = b.textContent.trim();\n return t === 'Following' || t === '已关注' || t === 'Friends' || t === '互关';\n });\n if (isFollowing) return [{ status: 'Already following', username: username }];\n return [{ status: 'Follow button not found', username: username }];\n }\n followBtn.click();\n await new Promise(r => setTimeout(r, 2000));\n return [{ status: 'Followed', username: username }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "tiktok/follow.yaml"
+ },
+ {
+ "site": "tiktok",
+ "name": "following",
+ "description": "List accounts you follow on TikTok",
+ "domain": "www.tiktok.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "Number of accounts"
+ }
+ ],
+ "columns": [
+ "index",
+ "username",
+ "name"
+ ],
+ "pipeline": [
+ {
+ "navigate": {
+ "url": "https://www.tiktok.com/following",
+ "settleMs": 5000
+ }
+ },
+ {
+ "evaluate": "(() => {\n const limit = ${{ args.limit }};\n const links = Array.from(document.querySelectorAll('a[href*=\"/@\"]'))\n .filter(function(a) {\n const text = a.textContent.trim();\n return text.length > 1 && text.length < 80 &&\n !text.includes('Profile') && !text.includes('More') && !text.includes('Upload');\n });\n\n const seen = {};\n const results = [];\n for (const a of links) {\n const match = a.href.match(/@([^/]+)/);\n const username = match ? match[1] : '';\n if (!username || seen[username]) continue;\n seen[username] = true;\n const raw = a.textContent.trim();\n const name = raw.replace(username, '').replace('@', '').trim();\n results.push({\n index: results.length + 1,\n username: username,\n name: name || username,\n });\n if (results.length >= limit) break;\n }\n return results;\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "tiktok/following.yaml"
+ },
+ {
+ "site": "tiktok",
+ "name": "friends",
+ "description": "Get TikTok friend suggestions",
+ "domain": "www.tiktok.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "Number of suggestions"
+ }
+ ],
+ "columns": [
+ "index",
+ "username",
+ "name"
+ ],
+ "pipeline": [
+ {
+ "navigate": {
+ "url": "https://www.tiktok.com/friends",
+ "settleMs": 5000
+ }
+ },
+ {
+ "evaluate": "(() => {\n const limit = ${{ args.limit }};\n const links = Array.from(document.querySelectorAll('a[href*=\"/@\"]'))\n .filter(function(a) {\n const text = a.textContent.trim();\n return text.length > 1 && text.length < 80 &&\n !text.includes('Profile') && !text.includes('More') && !text.includes('Upload');\n });\n\n const seen = {};\n const results = [];\n for (const a of links) {\n const match = a.href.match(/@([^/]+)/);\n const username = match ? match[1] : '';\n if (!username || seen[username]) continue;\n seen[username] = true;\n const raw = a.textContent.trim();\n const hasFollow = raw.includes('Follow');\n const name = raw.replace('Follow', '').replace(username, '').replace('@', '').trim();\n results.push({\n index: results.length + 1,\n username: username,\n name: name || username,\n });\n if (results.length >= limit) break;\n }\n return results;\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "tiktok/friends.yaml"
+ },
+ {
+ "site": "tiktok",
+ "name": "like",
+ "description": "Like a TikTok video",
+ "domain": "www.tiktok.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "url",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "TikTok video URL"
+ }
+ ],
+ "columns": [
+ "status",
+ "likes",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": {
+ "url": "${{ args.url }}",
+ "settleMs": 6000
+ }
+ },
+ {
+ "evaluate": "(async () => {\n const url = ${{ args.url | json }};\n const btn = document.querySelector('[data-e2e=\"like-icon\"]');\n if (!btn) throw new Error('Like button not found - make sure you are logged in');\n const container = btn.closest('button') || btn.closest('[role=\"button\"]') || btn;\n const aria = (container.getAttribute('aria-label') || '').toLowerCase();\n const color = window.getComputedStyle(btn).color;\n const isLiked = aria.includes('unlike') || aria.includes('取消点赞') ||\n (color && (color.includes('255, 65') || color.includes('fe2c55')));\n if (isLiked) {\n const count = document.querySelector('[data-e2e=\"like-count\"]');\n return [{ status: 'Already liked', likes: count ? count.textContent.trim() : '-', url: url }];\n }\n container.click();\n await new Promise(r => setTimeout(r, 2000));\n const count = document.querySelector('[data-e2e=\"like-count\"]');\n return [{ status: 'Liked', likes: count ? count.textContent.trim() : '-', url: url }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "tiktok/like.yaml"
+ },
+ {
+ "site": "tiktok",
+ "name": "live",
+ "description": "Browse live streams on TikTok",
+ "domain": "www.tiktok.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 10,
+ "required": false,
+ "positional": false,
+ "help": "Number of streams"
+ }
+ ],
+ "columns": [
+ "index",
+ "streamer",
+ "viewers",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": {
+ "url": "https://www.tiktok.com/live",
+ "settleMs": 5000
+ }
+ },
+ {
+ "evaluate": "(() => {\n const limit = ${{ args.limit }};\n // Sidebar live list has structured data\n const items = document.querySelectorAll('[data-e2e=\"live-side-nav-item\"]');\n const sidebar = Array.from(items).slice(0, limit).map(function(el, i) {\n const nameEl = el.querySelector('[data-e2e=\"live-side-nav-name\"]');\n const countEl = el.querySelector('[data-e2e=\"person-count\"]');\n const link = el.querySelector('a');\n return {\n index: i + 1,\n streamer: nameEl ? nameEl.textContent.trim() : '',\n viewers: countEl ? countEl.textContent.trim() : '-',\n url: link ? link.href : '',\n };\n });\n\n if (sidebar.length > 0) return sidebar;\n\n // Fallback: main content cards\n const cards = document.querySelectorAll('[data-e2e=\"discover-list-live-card\"]');\n return Array.from(cards).slice(0, limit).map(function(card, i) {\n const text = card.textContent.trim().replace(/\\s+/g, ' ');\n const link = card.querySelector('a[href*=\"/live\"]');\n const viewerMatch = text.match(/(\\d[\\d,.]*)\\s*watching/);\n return {\n index: i + 1,\n streamer: text.replace(/LIVE.*$/, '').trim().substring(0, 40),\n viewers: viewerMatch ? viewerMatch[1] : '-',\n url: link ? link.href : '',\n };\n });\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "tiktok/live.yaml"
+ },
+ {
+ "site": "tiktok",
+ "name": "notifications",
+ "description": "Get TikTok notifications (likes, comments, mentions, followers)",
+ "domain": "www.tiktok.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 15,
+ "required": false,
+ "positional": false,
+ "help": "Number of notifications"
+ },
+ {
+ "name": "type",
+ "type": "str",
+ "default": "all",
+ "required": false,
+ "positional": false,
+ "help": "Notification type",
+ "choices": [
+ "all",
+ "likes",
+ "comments",
+ "mentions",
+ "followers"
+ ]
+ }
+ ],
+ "columns": [
+ "index",
+ "text"
+ ],
+ "pipeline": [
+ {
+ "navigate": {
+ "url": "https://www.tiktok.com/following",
+ "settleMs": 5000
+ }
+ },
+ {
+ "evaluate": "(async () => {\n const limit = ${{ args.limit }};\n const type = ${{ args.type | json }};\n const wait = (ms) => new Promise(r => setTimeout(r, ms));\n\n // Click inbox icon to open notifications panel\n const inboxIcon = document.querySelector('[data-e2e=\"inbox-icon\"]');\n if (inboxIcon) inboxIcon.click();\n await wait(1500);\n\n // Click specific tab if needed\n if (type !== 'all') {\n const tab = document.querySelector('[data-e2e=\"' + type + '\"]');\n if (tab) {\n tab.click();\n await wait(1500);\n }\n }\n\n const items = document.querySelectorAll('[data-e2e=\"inbox-list\"] > div, [data-e2e=\"inbox-list\"] [role=\"button\"]');\n return Array.from(items)\n .filter(el => el.textContent.trim().length > 5)\n .slice(0, limit)\n .map((el, i) => ({\n index: i + 1,\n text: el.textContent.trim().replace(/\\s+/g, ' ').substring(0, 150),\n }));\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "tiktok/notifications.yaml"
+ },
+ {
+ "site": "tiktok",
+ "name": "profile",
+ "description": "Get TikTok user profile info",
+ "domain": "www.tiktok.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "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 () => {\n const username = ${{ args.username | json }};\n const res = await fetch('https://www.tiktok.com/@' + encodeURIComponent(username), { credentials: 'include' });\n if (!res.ok) throw new Error('User not found: ' + username);\n const html = await res.text();\n const idx = html.indexOf('__UNIVERSAL_DATA_FOR_REHYDRATION__');\n if (idx === -1) throw new Error('Could not parse profile data');\n const start = html.indexOf('>', idx) + 1;\n const end = html.indexOf('', start);\n const data = JSON.parse(html.substring(start, end));\n const ud = data['__DEFAULT_SCOPE__'] && data['__DEFAULT_SCOPE__']['webapp.user-detail'];\n const u = ud && ud.userInfo && ud.userInfo.user;\n const s = ud && ud.userInfo && ud.userInfo.stats;\n if (!u) throw new Error('User not found: ' + username);\n return [{\n username: u.uniqueId || username,\n name: u.nickname || '',\n bio: (u.signature || '').replace(/\\n/g, ' ').substring(0, 120),\n followers: s && s.followerCount || 0,\n following: s && s.followingCount || 0,\n likes: s && s.heartCount || 0,\n videos: s && s.videoCount || 0,\n verified: u.verified ? 'Yes' : 'No',\n }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "tiktok/profile.yaml"
+ },
+ {
+ "site": "tiktok",
+ "name": "save",
+ "description": "Add a TikTok video to Favorites",
+ "domain": "www.tiktok.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "url",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "TikTok video URL"
+ }
+ ],
+ "columns": [
+ "status",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": {
+ "url": "${{ args.url }}",
+ "settleMs": 6000
+ }
+ },
+ {
+ "evaluate": "(async () => {\n const url = ${{ args.url | json }};\n const btn = document.querySelector('[data-e2e=\"bookmark-icon\"]') ||\n document.querySelector('[data-e2e=\"collect-icon\"]');\n if (!btn) throw new Error('Favorites button not found - make sure you are logged in');\n const container = btn.closest('button') || btn.closest('[role=\"button\"]') || btn;\n const aria = (container.getAttribute('aria-label') || '').toLowerCase();\n if (aria.includes('remove from favorites') || aria.includes('取消收藏')) {\n return [{ status: 'Already in Favorites', url: url }];\n }\n container.click();\n await new Promise(r => setTimeout(r, 2000));\n return [{ status: 'Added to Favorites', url: url }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "tiktok/save.yaml"
+ },
+ {
+ "site": "tiktok",
+ "name": "search",
+ "description": "Search TikTok videos",
+ "domain": "www.tiktok.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "query",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Search query"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 10,
+ "required": false,
+ "positional": false,
+ "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 () => {\n const query = ${{ args.query | json }};\n const limit = ${{ args.limit }};\n const res = await fetch('/api/search/general/full/?keyword=' + encodeURIComponent(query) + '&offset=0&count=' + limit + '&aid=1988', { credentials: 'include' });\n if (!res.ok) throw new Error('Search failed: HTTP ' + res.status);\n const data = await res.json();\n const items = (data.data || []).filter(function(i) { return i.type === 1 && i.item; });\n return items.slice(0, limit).map(function(i, idx) {\n var v = i.item;\n var a = v.author || {};\n var s = v.stats || {};\n return {\n rank: idx + 1,\n desc: (v.desc || '').replace(/\\n/g, ' ').substring(0, 100),\n author: a.uniqueId || '',\n url: (a.uniqueId && v.id) ? 'https://www.tiktok.com/@' + a.uniqueId + '/video/' + v.id : '',\n plays: s.playCount || 0,\n likes: s.diggCount || 0,\n comments: s.commentCount || 0,\n shares: s.shareCount || 0,\n };\n });\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "tiktok/search.yaml"
+ },
+ {
+ "site": "tiktok",
+ "name": "unfollow",
+ "description": "Unfollow a TikTok user",
+ "domain": "www.tiktok.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "TikTok username (without @)"
+ }
+ ],
+ "columns": [
+ "status",
+ "username"
+ ],
+ "pipeline": [
+ {
+ "navigate": {
+ "url": "https://www.tiktok.com/@${{ args.username }}",
+ "settleMs": 6000
+ }
+ },
+ {
+ "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const buttons = Array.from(document.querySelectorAll('button, [role=\"button\"]'));\n const followingBtn = buttons.find(function(b) {\n var text = b.textContent.trim();\n return text === 'Following' || text === '已关注' || text === 'Friends' || text === '互关';\n });\n if (!followingBtn) {\n return [{ status: 'Not following this user', username: username }];\n }\n followingBtn.click();\n await new Promise(r => setTimeout(r, 2000));\n // Confirm unfollow if dialog appears\n var allBtns = Array.from(document.querySelectorAll('button'));\n var confirm = allBtns.find(function(b) {\n var t = b.textContent.trim();\n return t === 'Unfollow' || t === '取消关注';\n });\n if (confirm) {\n confirm.click();\n await new Promise(r => setTimeout(r, 1500));\n }\n return [{ status: 'Unfollowed', username: username }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "tiktok/unfollow.yaml"
+ },
+ {
+ "site": "tiktok",
+ "name": "unlike",
+ "description": "Unlike a TikTok video",
+ "domain": "www.tiktok.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "url",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "TikTok video URL"
+ }
+ ],
+ "columns": [
+ "status",
+ "likes",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": {
+ "url": "${{ args.url }}",
+ "settleMs": 6000
+ }
+ },
+ {
+ "evaluate": "(async () => {\n const url = ${{ args.url | json }};\n const btn = document.querySelector('[data-e2e=\"like-icon\"]');\n if (!btn) throw new Error('Like button not found - make sure you are logged in');\n const container = btn.closest('button') || btn.closest('[role=\"button\"]') || btn;\n const aria = (container.getAttribute('aria-label') || '').toLowerCase();\n const color = window.getComputedStyle(btn).color;\n const isLiked = aria.includes('unlike') || aria.includes('取消点赞') ||\n (color && (color.includes('255, 65') || color.includes('fe2c55')));\n if (!isLiked) {\n const count = document.querySelector('[data-e2e=\"like-count\"]');\n return [{ status: 'Not liked', likes: count ? count.textContent.trim() : '-', url: url }];\n }\n container.click();\n await new Promise(r => setTimeout(r, 2000));\n const count = document.querySelector('[data-e2e=\"like-count\"]');\n return [{ status: 'Unliked', likes: count ? count.textContent.trim() : '-', url: url }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "tiktok/unlike.yaml"
+ },
+ {
+ "site": "tiktok",
+ "name": "unsave",
+ "description": "Remove a TikTok video from Favorites",
+ "domain": "www.tiktok.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "url",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "TikTok video URL"
+ }
+ ],
+ "columns": [
+ "status",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": {
+ "url": "${{ args.url }}",
+ "settleMs": 6000
+ }
+ },
+ {
+ "evaluate": "(async () => {\n const url = ${{ args.url | json }};\n const btn = document.querySelector('[data-e2e=\"bookmark-icon\"]') ||\n document.querySelector('[data-e2e=\"collect-icon\"]');\n if (!btn) throw new Error('Favorites button not found - make sure you are logged in');\n const container = btn.closest('button') || btn.closest('[role=\"button\"]') || btn;\n const aria = (container.getAttribute('aria-label') || '').toLowerCase();\n if (aria.includes('add to favorites') || aria.includes('收藏')) {\n if (!aria.includes('remove') && !aria.includes('取消')) {\n return [{ status: 'Not in Favorites', url: url }];\n }\n }\n container.click();\n await new Promise(r => setTimeout(r, 2000));\n return [{ status: 'Removed from Favorites', url: url }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "tiktok/unsave.yaml"
+ },
+ {
+ "site": "tiktok",
+ "name": "user",
+ "description": "Get recent videos from a TikTok user",
+ "domain": "www.tiktok.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "TikTok username (without @)"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 10,
+ "required": false,
+ "positional": false,
+ "help": "Number of videos"
+ }
+ ],
+ "columns": [
+ "index",
+ "views",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": {
+ "url": "https://www.tiktok.com/@${{ args.username }}",
+ "settleMs": 6000
+ }
+ },
+ {
+ "evaluate": "(() => {\n const limit = ${{ args.limit }};\n const username = ${{ args.username | json }};\n const links = Array.from(document.querySelectorAll('a[href*=\"/video/\"]'));\n const seen = {};\n const results = [];\n for (const a of links) {\n const href = a.href;\n if (seen[href]) continue;\n seen[href] = true;\n results.push({\n index: results.length + 1,\n views: a.textContent.trim() || '-',\n url: href,\n });\n if (results.length >= limit) break;\n }\n if (results.length === 0) throw new Error('No videos found for @' + username);\n return results;\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "tiktok/user.yaml"
+ },
+ {
+ "site": "v2ex",
+ "name": "hot",
+ "description": "V2EX 热门话题",
+ "domain": "www.v2ex.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "v2ex/hot.yaml"
+ },
+ {
+ "site": "v2ex",
+ "name": "latest",
+ "description": "V2EX 最新话题",
+ "domain": "www.v2ex.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "v2ex/latest.yaml"
+ },
+ {
+ "site": "v2ex",
+ "name": "member",
+ "description": "V2EX 用户资料",
+ "domain": "www.v2ex.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "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 }}"
+ }
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "v2ex/member.yaml"
+ },
+ {
+ "site": "v2ex",
+ "name": "node",
+ "description": "V2EX 节点话题列表",
+ "domain": "www.v2ex.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "name",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Node name (e.g. python, javascript, apple)"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 10,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "v2ex/node.yaml"
+ },
+ {
+ "site": "v2ex",
+ "name": "nodes",
+ "description": "V2EX 所有节点列表",
+ "domain": "www.v2ex.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 30,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "v2ex/nodes.yaml"
+ },
+ {
+ "site": "v2ex",
+ "name": "replies",
+ "description": "V2EX 主题回复列表",
+ "domain": "www.v2ex.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "id",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Topic ID"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "v2ex/replies.yaml"
+ },
+ {
+ "site": "v2ex",
+ "name": "topic",
+ "description": "V2EX 主题详情和回复",
+ "domain": "www.v2ex.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "id",
+ "type": "str",
+ "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
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "v2ex/topic.yaml"
+ },
+ {
+ "site": "v2ex",
+ "name": "user",
+ "description": "V2EX 用户发帖列表",
+ "domain": "www.v2ex.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "username",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Username"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 10,
+ "required": false,
+ "positional": false,
+ "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 }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "v2ex/user.yaml"
+ },
+ {
+ "site": "xiaoe",
+ "name": "catalog",
+ "description": "小鹅通课程目录(支持普通课程、专栏、大专栏)",
+ "domain": "h5.xet.citv.cn",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "url",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "课程页面 URL"
+ }
+ ],
+ "columns": [
+ "ch",
+ "chapter",
+ "no",
+ "title",
+ "type",
+ "resource_id",
+ "status"
+ ],
+ "pipeline": [
+ {
+ "navigate": "${{ args.url }}"
+ },
+ {
+ "wait": 8
+ },
+ {
+ "evaluate": "(async () => {\n var el = document.querySelector('#app');\n var store = (el && el.__vue__) ? el.__vue__.$store : null;\n if (!store) return [];\n var coreInfo = store.state.coreInfo || {};\n var resourceType = coreInfo.resource_type || 0;\n var origin = window.location.origin;\n var courseName = coreInfo.resource_name || '';\n\n function typeLabel(t) {\n return {1:'图文',2:'直播',3:'音频',4:'视频',6:'专栏',8:'大专栏'}[Number(t)] || String(t||'');\n }\n function buildUrl(item) {\n var u = item.jump_url || item.h5_url || item.url || '';\n return (u && !u.startsWith('http')) ? origin + u : u;\n }\n function clickTab(name) {\n var tabs = document.querySelectorAll('span, div');\n for (var i = 0; i < tabs.length; i++) {\n if (tabs[i].children.length === 0 && tabs[i].textContent.trim() === name) {\n tabs[i].click(); return;\n }\n }\n }\n\n clickTab('目录');\n await new Promise(function(r) { setTimeout(r, 2000); });\n\n // ===== 专栏 / 大专栏 =====\n if (resourceType === 6 || resourceType === 8) {\n await new Promise(function(r) { setTimeout(r, 1000); });\n var listData = [];\n var walkList = function(vm, depth) {\n if (!vm || depth > 6 || listData.length > 0) return;\n var d = vm.$data || {};\n var keys = ['columnList', 'SingleItemList', 'chapterChildren'];\n for (var ki = 0; ki < keys.length; ki++) {\n var arr = d[keys[ki]];\n if (arr && Array.isArray(arr) && arr.length > 0 && arr[0].resource_id) {\n for (var j = 0; j < arr.length; j++) {\n var item = arr[j];\n if (!item.resource_id || !/^[pvlai]_/.test(item.resource_id)) continue;\n listData.push({\n ch: 1, chapter: courseName, no: j + 1,\n title: item.resource_title || item.title || item.chapter_title || '',\n type: typeLabel(item.resource_type || item.chapter_type),\n resource_id: item.resource_id,\n url: buildUrl(item),\n status: item.finished_state === 1 ? '已完成' : (item.resource_count ? item.resource_count + '节' : ''),\n });\n }\n return;\n }\n }\n if (vm.$children) {\n for (var c = 0; c < vm.$children.length; c++) walkList(vm.$children[c], depth + 1);\n }\n };\n walkList(el.__vue__, 0);\n return listData;\n }\n\n // ===== 普通课程 =====\n var chapters = document.querySelectorAll('.chapter_box');\n for (var ci = 0; ci < chapters.length; ci++) {\n var vue = chapters[ci].__vue__;\n if (vue && typeof vue.getSecitonList === 'function' && (!vue.isShowSecitonsList || !vue.chapterChildren.length)) {\n if (vue.isShowSecitonsList) vue.isShowSecitonsList = false;\n try { vue.getSecitonList(); } catch(e) {}\n await new Promise(function(r) { setTimeout(r, 1500); });\n }\n }\n await new Promise(function(r) { setTimeout(r, 3000); });\n\n var result = [];\n chapters = document.querySelectorAll('.chapter_box');\n for (var cj = 0; cj < chapters.length; cj++) {\n var v = chapters[cj].__vue__;\n if (!v) continue;\n var chTitle = (v.chapterItem && v.chapterItem.chapter_title) || '';\n var children = v.chapterChildren || [];\n for (var ck = 0; ck < children.length; ck++) {\n var child = children[ck];\n var resId = child.resource_id || child.chapter_id || '';\n var chType = child.chapter_type || child.resource_type || 0;\n var urlPath = {1:'/v1/course/text/',2:'/v2/course/alive/',3:'/v1/course/audio/',4:'/v1/course/video/'}[Number(chType)];\n result.push({\n ch: cj + 1, chapter: chTitle, no: ck + 1,\n title: child.chapter_title || child.resource_title || '',\n type: typeLabel(chType),\n resource_id: resId,\n url: urlPath ? origin + urlPath + resId + '?type=2' : '',\n status: child.is_finish === 1 ? '已完成' : (child.learn_progress > 0 ? child.learn_progress + '%' : '未学'),\n });\n }\n }\n return result;\n})()\n"
+ },
+ {
+ "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 }}"
+ }
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "xiaoe/catalog.yaml"
+ },
+ {
+ "site": "xiaoe",
+ "name": "content",
+ "description": "提取小鹅通图文页面内容为文本",
+ "domain": "h5.xet.citv.cn",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "url",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "页面 URL"
+ }
+ ],
+ "columns": [
+ "title",
+ "content_length",
+ "image_count"
+ ],
+ "pipeline": [
+ {
+ "navigate": "${{ args.url }}"
+ },
+ {
+ "wait": 6
+ },
+ {
+ "evaluate": "(() => {\n var selectors = ['.rich-text-wrap','.content-wrap','.article-content','.text-content',\n '.course-detail','.detail-content','[class*=\"richtext\"]','[class*=\"rich-text\"]','.ql-editor'];\n var content = '';\n for (var i = 0; i < selectors.length; i++) {\n var el = document.querySelector(selectors[i]);\n if (el && el.innerText.trim().length > 50) { content = el.innerText.trim(); break; }\n }\n if (!content) content = (document.querySelector('main') || document.querySelector('#app') || document.body).innerText.trim();\n\n var images = [];\n document.querySelectorAll('img').forEach(function(img) {\n if (img.src && !img.src.startsWith('data:') && img.src.includes('xiaoe')) images.push(img.src);\n });\n return [{\n title: document.title,\n content: content,\n content_length: content.length,\n image_count: images.length,\n images: JSON.stringify(images.slice(0, 20)),\n }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "xiaoe/content.yaml"
+ },
+ {
+ "site": "xiaoe",
+ "name": "courses",
+ "description": "列出已购小鹅通课程(含 URL 和店铺名)",
+ "domain": "study.xiaoe-tech.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [],
+ "columns": [
+ "title",
+ "shop",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://study.xiaoe-tech.com/"
+ },
+ {
+ "wait": 8
+ },
+ {
+ "evaluate": "(async () => {\n // 切换到「内容」tab\n var tabs = document.querySelectorAll('span, div');\n for (var i = 0; i < tabs.length; i++) {\n if (tabs[i].children.length === 0 && tabs[i].textContent.trim() === '内容') {\n tabs[i].click();\n break;\n }\n }\n await new Promise(function(r) { setTimeout(r, 2000); });\n\n // 匹配课程卡片标题与 Vue 数据\n function matchEntry(title, vm, depth) {\n if (!vm || depth > 5) return null;\n var d = vm.$data || {};\n for (var k in d) {\n if (!Array.isArray(d[k])) continue;\n for (var j = 0; j < d[k].length; j++) {\n var e = d[k][j];\n if (!e || typeof e !== 'object') continue;\n var t = e.title || e.resource_name || '';\n if (t && title.includes(t.substring(0, 10))) return e;\n }\n }\n return vm.$parent ? matchEntry(title, vm.$parent, depth + 1) : null;\n }\n\n // 构造课程 URL\n function buildUrl(entry) {\n if (entry.h5_url) return entry.h5_url;\n if (entry.url) return entry.url;\n if (entry.app_id && entry.resource_id) {\n var base = 'https://' + entry.app_id + '.h5.xet.citv.cn';\n if (entry.resource_type === 6) return base + '/v1/course/column/' + entry.resource_id + '?type=3';\n return base + '/p/course/ecourse/' + entry.resource_id;\n }\n return '';\n }\n\n var cards = document.querySelectorAll('.course-card-list');\n var results = [];\n for (var c = 0; c < cards.length; c++) {\n var titleEl = cards[c].querySelector('.card-title-box');\n var title = titleEl ? titleEl.textContent.trim() : '';\n if (!title) continue;\n var entry = matchEntry(title, cards[c].__vue__, 0);\n results.push({\n title: title,\n shop: entry ? (entry.shop_name || entry.app_name || '') : '',\n url: entry ? buildUrl(entry) : '',\n });\n }\n return results;\n})()\n"
+ },
+ {
+ "map": {
+ "title": "${{ item.title }}",
+ "shop": "${{ item.shop }}",
+ "url": "${{ item.url }}"
+ }
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "xiaoe/courses.yaml"
+ },
+ {
+ "site": "xiaoe",
+ "name": "detail",
+ "description": "小鹅通课程详情(名称、价格、学员数、店铺)",
+ "domain": "h5.xet.citv.cn",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "url",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "课程页面 URL"
+ }
+ ],
+ "columns": [
+ "name",
+ "price",
+ "original_price",
+ "user_count",
+ "shop_name"
+ ],
+ "pipeline": [
+ {
+ "navigate": "${{ args.url }}"
+ },
+ {
+ "wait": 5
+ },
+ {
+ "evaluate": "(() => {\n var vm = (document.querySelector('#app') || {}).__vue__;\n if (!vm || !vm.$store) return [];\n var core = vm.$store.state.coreInfo || {};\n var goods = vm.$store.state.goodsInfo || {};\n var shop = ((vm.$store.state.compositeInfo || {}).shop_conf) || {};\n return [{\n name: core.resource_name || '',\n resource_id: core.resource_id || '',\n resource_type: core.resource_type || '',\n cover: core.resource_img || '',\n user_count: core.user_count || 0,\n price: goods.price ? (goods.price / 100).toFixed(2) : '0',\n original_price: goods.line_price ? (goods.line_price / 100).toFixed(2) : '0',\n is_free: goods.is_free || 0,\n shop_name: shop.shop_name || '',\n }];\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "xiaoe/detail.yaml"
+ },
+ {
+ "site": "xiaoe",
+ "name": "play-url",
+ "description": "小鹅通视频/音频/直播回放 M3U8 播放地址",
+ "domain": "h5.xet.citv.cn",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "url",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "小节页面 URL"
+ }
+ ],
+ "columns": [
+ "title",
+ "resource_id",
+ "m3u8_url",
+ "duration_sec",
+ "method"
+ ],
+ "pipeline": [
+ {
+ "navigate": "${{ args.url }}"
+ },
+ {
+ "wait": 2
+ },
+ {
+ "evaluate": "(async () => {\n var pageUrl = window.location.href;\n var origin = window.location.origin;\n var resourceId = (pageUrl.match(/[val]_[a-f0-9]+/) || [])[0] || '';\n var productId = (pageUrl.match(/product_id=([^&]+)/) || [])[1] || '';\n var appId = (origin.match(/(app[a-z0-9]+)\\./) || [])[1] || '';\n var isLive = resourceId.startsWith('l_') || pageUrl.includes('/alive/');\n var m3u8Url = '', method = '', title = document.title, duration = 0;\n\n // 深度搜索 Vue 组件树找 M3U8\n function searchVueM3u8() {\n var el = document.querySelector('#app');\n if (!el || !el.__vue__) return '';\n var walk = function(vm, d) {\n if (!vm || d > 10) return '';\n var data = vm.$data || {};\n for (var k in data) {\n if (k[0] === '_' || k[0] === '$') continue;\n var v = data[k];\n if (typeof v === 'string' && v.includes('.m3u8')) return v;\n if (typeof v === 'object' && v) {\n try {\n var s = JSON.stringify(v);\n var m = s.match(/https?:[^\"]*\\.m3u8[^\"]*/);\n if (m) return m[0].replace(/\\\\\\//g, '/');\n } catch(e) {}\n }\n }\n if (vm.$children) {\n for (var c = 0; c < vm.$children.length; c++) {\n var f = walk(vm.$children[c], d + 1);\n if (f) return f;\n }\n }\n return '';\n };\n return walk(el.__vue__, 0);\n }\n\n // ===== 视频课: detail_info → getPlayUrl =====\n if (!isLive && resourceId.startsWith('v_')) {\n try {\n var detailRes = await fetch(origin + '/xe.course.business.video.detail_info.get/2.0.0', {\n method: 'POST', credentials: 'include',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n 'bizData[resource_id]': resourceId,\n 'bizData[product_id]': productId || resourceId,\n 'bizData[opr_sys]': 'MacIntel',\n }),\n });\n var detail = await detailRes.json();\n var vi = (detail.data || {}).video_info || {};\n title = vi.file_name || title;\n duration = vi.video_length || 0;\n if (vi.play_sign) {\n var userId = (document.cookie.match(/ctx_user_id=([^;]+)/) || [])[1] || window.__user_id || '';\n var playRes = await fetch(origin + '/xe.material-center.play/getPlayUrl', {\n method: 'POST', credentials: 'include',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n org_app_id: appId, app_id: vi.material_app_id || appId,\n user_id: userId, play_sign: [vi.play_sign],\n play_line: 'A', opr_sys: 'MacIntel',\n }),\n });\n var playData = await playRes.json();\n if (playData.code === 0 && playData.data) {\n var m = JSON.stringify(playData.data).match(/https?:[^\"]*\\.m3u8[^\"]*/);\n if (m) { m3u8Url = m[0].replace(/\\\\u0026/g, '&').replace(/\\\\\\//g, '/'); method = 'api_direct'; }\n }\n }\n } catch(e) {}\n }\n\n // ===== 兜底: Performance API + Vue 搜索轮询 =====\n if (!m3u8Url) {\n for (var attempt = 0; attempt < 30; attempt++) {\n var entries = performance.getEntriesByType('resource');\n for (var i = 0; i < entries.length; i++) {\n if (entries[i].name.includes('.m3u8')) { m3u8Url = entries[i].name; method = 'perf_api'; break; }\n }\n if (!m3u8Url) { m3u8Url = searchVueM3u8(); if (m3u8Url) method = 'vue_search'; }\n if (m3u8Url) break;\n await new Promise(function(r) { setTimeout(r, 500); });\n }\n }\n\n if (!duration) {\n var vid = document.querySelector('video'), aud = document.querySelector('audio');\n if (vid && vid.duration && !isNaN(vid.duration)) duration = Math.round(vid.duration);\n if (aud && aud.duration && !isNaN(aud.duration)) duration = Math.round(aud.duration);\n }\n\n return [{ title: title, resource_id: resourceId, m3u8_url: m3u8Url, duration_sec: duration, method: method }];\n})()\n"
+ },
+ {
+ "map": {
+ "title": "${{ item.title }}",
+ "resource_id": "${{ item.resource_id }}",
+ "m3u8_url": "${{ item.m3u8_url }}",
+ "duration_sec": "${{ item.duration_sec }}",
+ "method": "${{ item.method }}"
+ }
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "xiaoe/play-url.yaml"
+ },
+ {
+ "site": "xiaohongshu",
+ "name": "feed",
+ "description": "小红书首页推荐 Feed (via Pinia Store Action)",
+ "domain": "www.xiaohongshu.com",
+ "strategy": "intercept",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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) }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "xiaohongshu/feed.yaml"
+ },
+ {
+ "site": "xiaohongshu",
+ "name": "notifications",
+ "description": "小红书通知 (mentions/likes/connections)",
+ "domain": "www.xiaohongshu.com",
+ "strategy": "intercept",
+ "browser": true,
+ "args": [
+ {
+ "name": "type",
+ "type": "str",
+ "default": "mentions",
+ "required": false,
+ "positional": false,
+ "help": "Notification type: mentions, likes, or connections"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "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) }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "xiaohongshu/notifications.yaml"
+ },
+ {
+ "site": "xueqiu",
+ "name": "earnings-date",
+ "description": "获取股票预计财报发布日期(公司大事)",
+ "domain": "xueqiu.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "symbol",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "股票代码,如 SH600519、SZ000858、00700"
+ },
+ {
+ "name": "next",
+ "type": "bool",
+ "default": false,
+ "required": false,
+ "positional": false,
+ "help": "仅返回最近一次未发布的财报日期"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 10,
+ "required": false,
+ "positional": false,
+ "help": "返回数量,默认 10"
+ }
+ ],
+ "columns": [
+ "date",
+ "report",
+ "status"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://xueqiu.com"
+ },
+ {
+ "evaluate": "(async () => {\n const symbol = (${{ args.symbol | json }} || '').toUpperCase();\n const onlyNext = ${{ args.next }};\n if (!symbol) throw new Error('Missing argument: symbol');\n const resp = await fetch(\n `https://stock.xueqiu.com/v5/stock/screener/event/list.json?symbol=${encodeURIComponent(symbol)}&page=1&size=100`,\n { credentials: 'include' }\n );\n if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');\n const d = await resp.json();\n if (!d.data || !d.data.items) throw new Error('获取失败: ' + JSON.stringify(d));\n\n // subtype 2 = 预计财报发布\n let items = d.data.items.filter(item => item.subtype === 2);\n\n const now = Date.now();\n let results = items.map(item => {\n const ts = item.timestamp;\n const dateStr = ts ? new Date(ts).toISOString().split('T')[0] : null;\n const isFuture = ts && ts > now;\n return {\n date: dateStr,\n report: item.message,\n status: isFuture ? '⏳ 未发布' : '✅ 已发布',\n _ts: ts,\n _future: isFuture\n };\n });\n\n if (onlyNext) {\n const future = results.filter(r => r._future).sort((a, b) => a._ts - b._ts);\n results = future.length ? [future[0]] : [];\n }\n\n return results;\n})()\n"
+ },
+ {
+ "map": {
+ "date": "${{ item.date }}",
+ "report": "${{ item.report }}",
+ "status": "${{ item.status }}"
+ }
+ },
+ {
+ "limit": "${{ args.limit }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "xueqiu/earnings-date.yaml"
+ },
+ {
+ "site": "xueqiu",
+ "name": "feed",
+ "description": "获取雪球首页时间线(关注用户的动态)",
+ "domain": "xueqiu.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "page",
+ "type": "int",
+ "default": 1,
+ "required": false,
+ "positional": false,
+ "help": "页码,默认 1"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "每页数量,默认 20"
+ }
+ ],
+ "columns": [
+ "author",
+ "text",
+ "likes",
+ "replies",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://xueqiu.com"
+ },
+ {
+ "evaluate": "(async () => {\n const page = ${{ args.page }};\n const count = ${{ args.limit }};\n const resp = await fetch(`https://xueqiu.com/v4/statuses/home_timeline.json?page=${page}&count=${count}`, {credentials: 'include'});\n if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');\n const d = await resp.json();\n \n const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim();\n const list = d.home_timeline || d.list || [];\n return list.map(item => {\n const user = item.user || {};\n return {\n id: item.id,\n text: strip(item.description).substring(0, 200),\n url: 'https://xueqiu.com/' + user.id + '/' + item.id,\n author: user.screen_name,\n likes: item.fav_count,\n retweets: item.retweet_count,\n replies: item.reply_count,\n created_at: item.created_at ? new Date(item.created_at).toISOString() : null\n };\n });\n})()\n"
+ },
+ {
+ "map": {
+ "author": "${{ item.author }}",
+ "text": "${{ item.text }}",
+ "likes": "${{ item.likes }}",
+ "replies": "${{ item.replies }}",
+ "url": "${{ item.url }}"
+ }
+ },
+ {
+ "limit": "${{ args.limit }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "xueqiu/feed.yaml"
+ },
+ {
+ "site": "xueqiu",
+ "name": "groups",
+ "description": "获取雪球自选股分组列表(含模拟组合)",
+ "domain": "xueqiu.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [],
+ "columns": [
+ "pid",
+ "name",
+ "count"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://xueqiu.com"
+ },
+ {
+ "evaluate": "(async () => {\n const resp = await fetch('https://stock.xueqiu.com/v5/stock/portfolio/list.json?category=1&size=20', {credentials: 'include'});\n if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');\n const d = await resp.json();\n if (!d.data || !d.data.stocks) throw new Error('获取失败,可能未登录');\n\n return d.data.stocks.map(g => ({\n pid: String(g.id),\n name: g.name,\n count: g.symbol_count || 0\n }));\n})()\n"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "xueqiu/groups.yaml"
+ },
+ {
+ "site": "xueqiu",
+ "name": "hot",
+ "description": "获取雪球热门动态",
+ "domain": "xueqiu.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "返回数量,默认 20,最大 50"
+ }
+ ],
+ "columns": [
+ "rank",
+ "author",
+ "text",
+ "likes",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://xueqiu.com"
+ },
+ {
+ "evaluate": "(async () => {\n const resp = await fetch('https://xueqiu.com/statuses/hot/listV3.json?source=hot&page=1', {credentials: 'include'});\n if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');\n const d = await resp.json();\n const list = d.list || [];\n \n const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim();\n return list.map((item, i) => {\n const user = item.user || {};\n return {\n rank: i + 1,\n text: strip(item.description).substring(0, 200),\n url: 'https://xueqiu.com/' + user.id + '/' + item.id,\n author: user.screen_name,\n likes: item.fav_count,\n retweets: item.retweet_count,\n replies: item.reply_count\n };\n });\n})()\n"
+ },
+ {
+ "map": {
+ "rank": "${{ item.rank }}",
+ "author": "${{ item.author }}",
+ "text": "${{ item.text }}",
+ "likes": "${{ item.likes }}",
+ "url": "${{ item.url }}"
+ }
+ },
+ {
+ "limit": "${{ args.limit }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "xueqiu/hot.yaml"
+ },
+ {
+ "site": "xueqiu",
+ "name": "hot-stock",
+ "description": "获取雪球热门股票榜",
+ "domain": "xueqiu.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "返回数量,默认 20,最大 50"
+ },
+ {
+ "name": "type",
+ "type": "str",
+ "default": "10",
+ "required": false,
+ "positional": false,
+ "help": "榜单类型 10=人气榜(默认) 12=关注榜"
+ }
+ ],
+ "columns": [
+ "rank",
+ "symbol",
+ "name",
+ "price",
+ "changePercent",
+ "heat"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://xueqiu.com"
+ },
+ {
+ "evaluate": "(async () => {\n const count = ${{ args.limit }};\n const type = ${{ args.type | json }};\n const resp = await fetch(`https://stock.xueqiu.com/v5/stock/hot_stock/list.json?size=${count}&type=${type}`, {credentials: 'include'});\n if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');\n const d = await resp.json();\n if (!d.data || !d.data.items) throw new Error('获取失败');\n return d.data.items.map((s, i) => ({\n rank: i + 1,\n symbol: s.symbol,\n name: s.name,\n price: s.current,\n changePercent: s.percent != null ? s.percent.toFixed(2) + '%' : null,\n heat: s.value,\n rank_change: s.rank_change,\n url: 'https://xueqiu.com/S/' + s.symbol\n }));\n})()\n"
+ },
+ {
+ "map": {
+ "rank": "${{ item.rank }}",
+ "symbol": "${{ item.symbol }}",
+ "name": "${{ item.name }}",
+ "price": "${{ item.price }}",
+ "changePercent": "${{ item.changePercent }}",
+ "heat": "${{ item.heat }}"
+ }
+ },
+ {
+ "limit": "${{ args.limit }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "xueqiu/hot-stock.yaml"
+ },
+ {
+ "site": "xueqiu",
+ "name": "kline",
+ "description": "获取雪球股票K线(历史行情)数据",
+ "domain": "xueqiu.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "symbol",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "股票代码,如 SH600519、SZ000858、AAPL"
+ },
+ {
+ "name": "days",
+ "type": "int",
+ "default": 14,
+ "required": false,
+ "positional": false,
+ "help": "回溯天数(默认14天)"
+ }
+ ],
+ "columns": [
+ "date",
+ "open",
+ "high",
+ "low",
+ "close",
+ "volume"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://xueqiu.com"
+ },
+ {
+ "evaluate": "(async () => {\n const symbol = (${{ args.symbol | json }} || '').toUpperCase();\n const days = parseInt(${{ args.days | json }}) || 14;\n if (!symbol) throw new Error('Missing argument: symbol');\n\n // begin = now minus days (for count=-N, returns N items ending at begin)\n const beginTs = Date.now();\n 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'});\n if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');\n const d = await resp.json();\n\n if (!d.data || !d.data.item || d.data.item.length === 0) return [];\n\n const columns = d.data.column || [];\n const items = d.data.item || [];\n const colIdx = {};\n columns.forEach((name, i) => { colIdx[name] = i; });\n\n function fmt(v) { return v == null ? null : v; }\n\n return items.map(row => ({\n date: colIdx.timestamp != null ? new Date(row[colIdx.timestamp]).toISOString().split('T')[0] : null,\n open: fmt(row[colIdx.open]),\n high: fmt(row[colIdx.high]),\n low: fmt(row[colIdx.low]),\n close: fmt(row[colIdx.close]),\n volume: fmt(row[colIdx.volume]),\n amount: fmt(row[colIdx.amount]),\n chg: fmt(row[colIdx.chg]),\n percent: fmt(row[colIdx.percent]),\n symbol: symbol\n }));\n})()\n"
+ },
+ {
+ "map": {
+ "date": "${{ item.date }}",
+ "open": "${{ item.open }}",
+ "high": "${{ item.high }}",
+ "low": "${{ item.low }}",
+ "close": "${{ item.close }}",
+ "volume": "${{ item.volume }}",
+ "percent": "${{ item.percent }}"
+ }
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "xueqiu/kline.yaml"
+ },
+ {
+ "site": "xueqiu",
+ "name": "search",
+ "description": "搜索雪球股票(代码或名称)",
+ "domain": "xueqiu.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "query",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "搜索关键词,如 茅台、AAPL、腾讯"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 10,
+ "required": false,
+ "positional": false,
+ "help": "返回数量,默认 10"
+ }
+ ],
+ "columns": [
+ "symbol",
+ "name",
+ "exchange",
+ "price",
+ "changePercent",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://xueqiu.com"
+ },
+ {
+ "evaluate": "(async () => {\n const query = ${{ args.query | json }};\n const count = ${{ args.limit }};\n const resp = await fetch(`https://xueqiu.com/stock/search.json?code=${encodeURIComponent(query)}&size=${count}`, {credentials: 'include'});\n if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');\n const d = await resp.json();\n return (d.stocks || []).map(s => {\n let symbol = '';\n if (s.exchange === 'SH' || s.exchange === 'SZ' || s.exchange === 'BJ') {\n symbol = s.code.startsWith(s.exchange) ? s.code : s.exchange + s.code;\n } else {\n symbol = s.code;\n }\n return {\n symbol: symbol,\n name: s.name,\n exchange: s.exchange,\n price: s.current,\n changePercent: s.percentage != null ? s.percentage.toFixed(2) + '%' : null,\n url: 'https://xueqiu.com/S/' + symbol\n };\n });\n})()\n"
+ },
+ {
+ "map": {
+ "symbol": "${{ item.symbol }}",
+ "name": "${{ item.name }}",
+ "exchange": "${{ item.exchange }}",
+ "price": "${{ item.price }}",
+ "changePercent": "${{ item.changePercent }}",
+ "url": "${{ item.url }}"
+ }
+ },
+ {
+ "limit": "${{ args.limit }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "xueqiu/search.yaml"
+ },
+ {
+ "site": "xueqiu",
+ "name": "stock",
+ "description": "获取雪球股票实时行情",
+ "domain": "xueqiu.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "symbol",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "股票代码,如 SH600519、SZ000858、AAPL、00700"
+ }
+ ],
+ "columns": [
+ "name",
+ "symbol",
+ "price",
+ "changePercent",
+ "marketCap"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://xueqiu.com"
+ },
+ {
+ "evaluate": "(async () => {\n const symbol = (${{ args.symbol | json }} || '').toUpperCase();\n if (!symbol) throw new Error('Missing argument: symbol');\n const resp = await fetch(`https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol=${encodeURIComponent(symbol)}`, {credentials: 'include'});\n if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');\n const d = await resp.json();\n if (!d.data || !d.data.items || d.data.items.length === 0) throw new Error('未找到股票: ' + symbol);\n \n function fmtAmount(v) {\n if (v == null) return null;\n if (Math.abs(v) >= 1e12) return (v / 1e12).toFixed(2) + '万亿';\n if (Math.abs(v) >= 1e8) return (v / 1e8).toFixed(2) + '亿';\n if (Math.abs(v) >= 1e4) return (v / 1e4).toFixed(2) + '万';\n return v.toString();\n }\n \n const item = d.data.items[0];\n const q = item.quote || {};\n const m = item.market || {};\n \n return [{\n name: q.name,\n symbol: q.symbol,\n exchange: q.exchange,\n currency: q.currency,\n price: q.current,\n change: q.chg,\n changePercent: q.percent != null ? q.percent.toFixed(2) + '%' : null,\n open: q.open,\n high: q.high,\n low: q.low,\n prevClose: q.last_close,\n amplitude: q.amplitude != null ? q.amplitude.toFixed(2) + '%' : null,\n volume: q.volume,\n amount: fmtAmount(q.amount),\n turnover_rate: q.turnover_rate != null ? q.turnover_rate.toFixed(2) + '%' : null,\n marketCap: fmtAmount(q.market_capital),\n floatMarketCap: fmtAmount(q.float_market_capital),\n ytdPercent: q.current_year_percent != null ? q.current_year_percent.toFixed(2) + '%' : null,\n market_status: m.status || null,\n time: q.timestamp ? new Date(q.timestamp).toISOString() : null,\n url: 'https://xueqiu.com/S/' + q.symbol\n }];\n})()\n"
+ },
+ {
+ "map": {
+ "name": "${{ item.name }}",
+ "symbol": "${{ item.symbol }}",
+ "price": "${{ item.price }}",
+ "changePercent": "${{ item.changePercent }}",
+ "marketCap": "${{ item.marketCap }}"
+ }
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "xueqiu/stock.yaml"
+ },
+ {
+ "site": "xueqiu",
+ "name": "watchlist",
+ "description": "获取雪球自选股/模拟组合股票列表",
+ "domain": "xueqiu.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "pid",
+ "type": "str",
+ "default": "-1",
+ "required": false,
+ "positional": false,
+ "help": "分组ID:-1=全部(默认) -4=模拟 -5=沪深 -6=美股 -7=港股 -10=实盘 0=持仓(通过 xueqiu groups 获取)"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 100,
+ "required": false,
+ "positional": false,
+ "help": "默认 100"
+ }
+ ],
+ "columns": [
+ "symbol",
+ "name",
+ "price",
+ "changePercent"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://xueqiu.com"
+ },
+ {
+ "evaluate": "(async () => {\n const pid = ${{ args.pid | json }} || '-1';\n const resp = await fetch(`https://stock.xueqiu.com/v5/stock/portfolio/stock/list.json?size=100&category=1&pid=${encodeURIComponent(pid)}`, {credentials: 'include'});\n if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');\n const d = await resp.json();\n if (!d.data || !d.data.stocks) throw new Error('获取失败,可能未登录');\n\n return d.data.stocks.map(s => ({\n symbol: s.symbol,\n name: s.name,\n price: s.current,\n change: s.chg,\n changePercent: s.percent != null ? s.percent.toFixed(2) + '%' : null,\n volume: s.volume,\n url: 'https://xueqiu.com/S/' + s.symbol\n }));\n})()\n"
+ },
+ {
+ "map": {
+ "symbol": "${{ item.symbol }}",
+ "name": "${{ item.name }}",
+ "price": "${{ item.price }}",
+ "changePercent": "${{ item.changePercent }}"
+ }
+ },
+ {
+ "limit": "${{ args.limit }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "xueqiu/watchlist.yaml"
+ },
+ {
+ "site": "zhihu",
+ "name": "hot",
+ "description": "知乎热榜",
+ "domain": "www.zhihu.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 20,
+ "required": false,
+ "positional": false,
+ "help": "Number of items to return"
+ }
+ ],
+ "columns": [
+ "rank",
+ "title",
+ "heat",
+ "answers"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.zhihu.com"
+ },
+ {
+ "evaluate": "(async () => {\n const res = await fetch('https://www.zhihu.com/api/v3/feed/topstory/hot-lists/total?limit=50', {\n credentials: 'include'\n });\n const text = await res.text();\n const d = JSON.parse(\n text.replace(/(\"id\"\\s*:\\s*)(\\d{16,})/g, '$1\"$2\"')\n );\n return (d?.data || []).map((item) => {\n const t = item.target || {};\n const questionId = t.id == null ? '' : String(t.id);\n return {\n title: t.title,\n url: 'https://www.zhihu.com/question/' + questionId,\n answer_count: t.answer_count,\n follower_count: t.follower_count,\n heat: item.detail_text || '',\n };\n });\n})()\n"
+ },
+ {
+ "map": {
+ "rank": "${{ index + 1 }}",
+ "title": "${{ item.title }}",
+ "heat": "${{ item.heat }}",
+ "answers": "${{ item.answer_count }}",
+ "url": "${{ item.url }}"
+ }
+ },
+ {
+ "limit": "${{ args.limit }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "zhihu/hot.yaml"
+ },
+ {
+ "site": "zhihu",
+ "name": "search",
+ "description": "知乎搜索",
+ "domain": "www.zhihu.com",
+ "strategy": "cookie",
+ "browser": true,
+ "args": [
+ {
+ "name": "query",
+ "type": "str",
+ "required": true,
+ "positional": true,
+ "help": "Search query"
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 10,
+ "required": false,
+ "positional": false,
+ "help": "Number of results"
+ }
+ ],
+ "columns": [
+ "rank",
+ "title",
+ "type",
+ "author",
+ "votes",
+ "url"
+ ],
+ "pipeline": [
+ {
+ "navigate": "https://www.zhihu.com"
+ },
+ {
+ "evaluate": "(async () => {\n const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(//g, '').replace(/<\\/em>/g, '').trim();\n const keyword = ${{ args.query | json }};\n const limit = ${{ args.limit }};\n const res = await fetch('https://www.zhihu.com/api/v4/search_v3?q=' + encodeURIComponent(keyword) + '&t=general&offset=0&limit=' + limit, {\n credentials: 'include'\n });\n const d = await res.json();\n return (d?.data || [])\n .filter(item => item.type === 'search_result')\n .map(item => {\n const obj = item.object || {};\n const q = obj.question || {};\n return {\n type: obj.type,\n title: strip(obj.title || q.name || ''),\n excerpt: strip(obj.excerpt || '').substring(0, 100),\n author: obj.author?.name || '',\n votes: obj.voteup_count || 0,\n url: obj.type === 'answer'\n ? 'https://www.zhihu.com/question/' + q.id + '/answer/' + obj.id\n : obj.type === 'article'\n ? 'https://zhuanlan.zhihu.com/p/' + obj.id\n : 'https://www.zhihu.com/question/' + obj.id,\n };\n });\n})()\n"
+ },
+ {
+ "map": {
+ "rank": "${{ index + 1 }}",
+ "title": "${{ item.title }}",
+ "type": "${{ item.type }}",
+ "author": "${{ item.author }}",
+ "votes": "${{ item.votes }}",
+ "url": "${{ item.url }}"
+ }
+ },
+ {
+ "limit": "${{ args.limit }}"
+ }
+ ],
+ "type": "yaml",
+ "sourceFile": "zhihu/search.yaml"
+ }
+]
\ No newline at end of file
diff --git a/clis/saky/commands.test.ts b/clis/saky/commands.test.ts
new file mode 100644
index 000000000..0d59cb34a
--- /dev/null
+++ b/clis/saky/commands.test.ts
@@ -0,0 +1,160 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { Strategy, getRegistry } from '@jackwener/opencli/registry';
+import './docs.js';
+import './tool.js';
+import './electronics.js';
+import './formula.js';
+
+describe('saky command registration', () => {
+ it('registers docs and all three query commands', () => {
+ const docs = getRegistry().get('saky/docs');
+ const tool = getRegistry().get('saky/tool');
+ const electronics = getRegistry().get('saky/electronics');
+ const formula = getRegistry().get('saky/formula');
+ const alias = getRegistry().get('saky/elec');
+
+ expect(docs).toBeDefined();
+ expect(tool).toBeDefined();
+ expect(electronics).toBeDefined();
+ expect(formula).toBeDefined();
+ expect(alias).toBe(electronics);
+ expect(docs?.strategy).toBe(Strategy.PUBLIC);
+ expect(tool?.strategy).toBe(Strategy.PUBLIC);
+ expect(electronics?.strategy).toBe(Strategy.PUBLIC);
+ expect(formula?.strategy).toBe(Strategy.PUBLIC);
+ expect(docs?.browser).toBe(false);
+ expect(tool?.browser).toBe(false);
+ expect(electronics?.browser).toBe(false);
+ expect(formula?.browser).toBe(false);
+ });
+});
+
+describe('saky docs command', () => {
+ it('returns the dataset summary rows', async () => {
+ const docs = getRegistry().get('saky/docs');
+ expect(docs?.func).toBeTypeOf('function');
+
+ const result = await docs!.func!(null as never, {});
+ expect(result).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ command: 'saky/tool', endpoint: '/warehouse_ai/product_label_info_tool' }),
+ expect.objectContaining({ command: 'saky/electronics', endpoint: '/warehouse_ai/product_label_info_electronics' }),
+ expect.objectContaining({ command: 'saky/formula', endpoint: '/warehouse_ai/product_label_info_formula' }),
+ ]),
+ );
+ });
+});
+
+describe('saky query commands', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ delete process.env.SAKY_APPCODE;
+ delete process.env.SAKY_APP_CODE;
+ delete process.env.SAKY_BASE_URL;
+ delete process.env.SAKY_PT;
+ });
+
+ it('uses APPCODE auth and maps tool rows', async () => {
+ process.env.SAKY_APPCODE = 'test-code';
+ const tool = getRegistry().get('saky/tool');
+ expect(tool?.func).toBeTypeOf('function');
+
+ const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
+ errCode: 0,
+ errMsg: 'success',
+ requestId: 'req-1',
+ data: {
+ totalNum: 12,
+ pageSize: 5,
+ pageNum: 2,
+ rows: [
+ { id: '1', cpmc: '成人牙刷', cpmcdh: '成人牙刷-产品', cptm: '6900001', cppl: '成人牙刷', pt: '20260409' },
+ ],
+ },
+ }), { status: 200 }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ const result = await tool!.func!(null as never, {
+ 'page-num': 2,
+ 'page-size': 5,
+ pt: '20260409',
+ 'return-total-num': false,
+ });
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ 'https://dataapi.weimeizi.com/warehouse/warehouse_ai/product_label_info_tool?pageNum=2&pageSize=5&pt=20260409&returnTotalNum=false',
+ expect.objectContaining({
+ method: 'GET',
+ headers: expect.objectContaining({
+ Authorization: 'APPCODE test-code',
+ 'Content-Type': 'application/json',
+ }),
+ }),
+ );
+ expect(result).toEqual([
+ expect.objectContaining({
+ id: '1',
+ cpmc: '成人牙刷',
+ cptm: '6900001',
+ _requestId: 'req-1',
+ _pageNum: 2,
+ _pageSize: 5,
+ _totalNum: 12,
+ }),
+ ]);
+ });
+
+ it('accepts app-code and base-url overrides', async () => {
+ const formula = getRegistry().get('saky/formula');
+ expect(formula?.func).toBeTypeOf('function');
+
+ const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
+ errCode: 0,
+ errMsg: 'success',
+ requestId: 'req-2',
+ data: {
+ totalNum: 1,
+ pageSize: 1,
+ pageNum: 1,
+ rows: [
+ { id: 9, cpmc: '美白牙膏', cptxm: '6900009', cppl: '牙膏', pt: '20260409' },
+ ],
+ },
+ }), { status: 200 }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ await formula!.func!(null as never, {
+ 'app-code': 'override-code',
+ 'base-url': 'https://internal.example.com/warehouse/',
+ 'page-num': 1,
+ 'page-size': 1,
+ pt: '20260409',
+ });
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ 'https://internal.example.com/warehouse/warehouse_ai/product_label_info_formula?pageNum=1&pageSize=1&pt=20260409&returnTotalNum=true',
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: 'APPCODE override-code',
+ }),
+ }),
+ );
+ });
+
+ it('raises a helpful error for warehouse SQL failures', async () => {
+ process.env.SAKY_APPCODE = 'test-code';
+ const electronics = getRegistry().get('saky/electronics');
+ expect(electronics?.func).toBeTypeOf('function');
+
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
+ errCode: 1108110565,
+ errMsg: 'An error occurred while executing the SQL statement.',
+ requestId: 'req-3',
+ data: { rows: [] },
+ }), { status: 200 })));
+
+ await expect(electronics!.func!(null as never, { pt: '20260409' })).rejects.toThrow(
+ 'SAKY API error 1108110565',
+ );
+ });
+});
diff --git a/clis/saky/common.ts b/clis/saky/common.ts
new file mode 100644
index 000000000..782ad38f4
--- /dev/null
+++ b/clis/saky/common.ts
@@ -0,0 +1,244 @@
+import { CliError, ConfigError, getErrorMessage } from '@jackwener/opencli/errors';
+
+export const SAKY_SITE = 'saky';
+export const SAKY_DOMAIN = 'dataapi.weimeizi.com';
+export const SAKY_DEFAULT_BASE_URL = `https://${SAKY_DOMAIN}/warehouse`;
+export const SAKY_DOC_TITLE = '产品标签信息导出 API 文档 V1.0.0';
+export const SAKY_DOC_SAMPLE_PT = '20260409';
+
+export type SakyDatasetKey = 'tool' | 'electronics' | 'formula';
+
+export interface SakyDatasetConfig {
+ command: SakyDatasetKey;
+ title: string;
+ description: string;
+ endpoint: string;
+ columns: string[];
+ keyFields: string[];
+}
+
+export const SAKY_DATASETS: Record = {
+ tool: {
+ command: 'tool',
+ title: '工具类标签查询',
+ description: '查询工具类产品标签信息(如牙刷、牙线)',
+ endpoint: '/warehouse_ai/product_label_info_tool',
+ columns: ['id', 'cpmc', 'cpmcdh', 'cptm', 'cppl', 'sqrq', 'bbh', 'pt'],
+ keyFields: ['id', 'cpmc', 'cptm', 'cppl', 'pt'],
+ },
+ electronics: {
+ command: 'electronics',
+ title: '电子类标签查询',
+ description: '查询电子类产品标签信息(如电动牙刷、冲牙器)',
+ endpoint: '/warehouse_ai/product_label_info_electronics',
+ columns: ['id', 'cpmc', 'cpmczj', 'cptm', 'cppl', 'xh', 'ys', 'pt'],
+ keyFields: ['id', 'cpmc', 'cptm', 'cppl', 'pt'],
+ },
+ formula: {
+ command: 'formula',
+ title: '配方类标签查询',
+ description: '查询配方类产品标签和成分信息(如牙膏、漱口水)',
+ endpoint: '/warehouse_ai/product_label_info_formula',
+ columns: ['id', 'cpmc', 'cpmc1', 'cptxm', 'fl', 'cppl', 'jhl', 'pt'],
+ keyFields: ['id', 'cpmc', 'cptxm', 'cppl', 'pt'],
+ },
+};
+
+interface SakyListResponse> {
+ errCode?: number;
+ errMsg?: string;
+ requestId?: string;
+ data?: {
+ totalNum?: number;
+ pageSize?: number;
+ pageNum?: number;
+ rows?: T[];
+ };
+}
+
+function trimOrEmpty(value: unknown): string {
+ return value == null ? '' : String(value).trim();
+}
+
+function firstNonEmpty(...values: unknown[]): string {
+ for (const value of values) {
+ const text = trimOrEmpty(value);
+ if (text) return text;
+ }
+ return '';
+}
+
+function formatShanghaiDate(date: Date): string {
+ const parts = new Intl.DateTimeFormat('en-CA', {
+ timeZone: 'Asia/Shanghai',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ }).formatToParts(date);
+ const year = parts.find((part) => part.type === 'year')?.value ?? '';
+ const month = parts.find((part) => part.type === 'month')?.value ?? '';
+ const day = parts.find((part) => part.type === 'day')?.value ?? '';
+ return `${year}${month}${day}`;
+}
+
+function defaultPt(): string {
+ return formatShanghaiDate(new Date(Date.now() - 24 * 60 * 60 * 1000));
+}
+
+function parsePositiveInt(value: unknown, name: string, fallback: number, max: number): number {
+ if (value === undefined || value === null || value === '') return fallback;
+ const numeric = Number(value);
+ if (!Number.isInteger(numeric) || numeric <= 0) {
+ throw new CliError('ARGUMENT', `${name} must be a positive integer.`, `Pass --${name} .`);
+ }
+ return Math.min(numeric, max);
+}
+
+function parseBoolean(value: unknown, name: string, fallback: boolean): boolean {
+ if (value === undefined || value === null || value === '') return fallback;
+ if (typeof value === 'boolean') return value;
+ const normalized = trimOrEmpty(value).toLowerCase();
+ if (['true', '1', 'yes', 'y'].includes(normalized)) return true;
+ if (['false', '0', 'no', 'n'].includes(normalized)) return false;
+ throw new CliError('ARGUMENT', `${name} must be true or false.`, `Pass --${name} true or --${name} false.`);
+}
+
+function resolveAppCode(kwargs: Record): string {
+ const appCode = firstNonEmpty(
+ kwargs['app-code'],
+ kwargs.appCode,
+ process.env.SAKY_APPCODE,
+ process.env.SAKY_APP_CODE,
+ );
+ if (!appCode) {
+ throw new ConfigError(
+ 'Missing SAKY APPCODE.',
+ 'Pass --app-code or set SAKY_APPCODE / SAKY_APP_CODE before running this command.',
+ );
+ }
+ return appCode;
+}
+
+function resolveBaseUrl(kwargs: Record): string {
+ const baseUrl = firstNonEmpty(kwargs['base-url'], kwargs.baseUrl, process.env.SAKY_BASE_URL, SAKY_DEFAULT_BASE_URL)
+ .replace(/\/+$/, '');
+ if (!/^https?:\/\//i.test(baseUrl)) {
+ throw new ConfigError(
+ 'SAKY base URL must start with http:// or https://.',
+ `Current value: ${baseUrl}`,
+ );
+ }
+ return baseUrl;
+}
+
+export function buildSakyFooter(kwargs: Record): string {
+ const pt = firstNonEmpty(kwargs.pt, process.env.SAKY_PT, defaultPt());
+ const pageNum = parsePositiveInt(kwargs['page-num'] ?? kwargs.pageNum, 'page-num', 1, 100000);
+ const pageSize = parsePositiveInt(kwargs['page-size'] ?? kwargs.pageSize, 'page-size', 10, 1000);
+ return `pt=${pt} · page=${pageNum} · size=${pageSize}`;
+}
+
+export function sakyDocsRows(): Record[] {
+ return Object.values(SAKY_DATASETS).map((dataset) => ({
+ command: `${SAKY_SITE}/${dataset.command}`,
+ endpoint: dataset.endpoint,
+ description: dataset.description,
+ sample_pt: SAKY_DOC_SAMPLE_PT,
+ key_fields: dataset.keyFields.join(', '),
+ }));
+}
+
+export async function querySakyDataset(
+ datasetKey: SakyDatasetKey,
+ kwargs: Record,
+): Promise[]> {
+ const dataset = SAKY_DATASETS[datasetKey];
+ const pageNum = parsePositiveInt(kwargs['page-num'] ?? kwargs.pageNum, 'page-num', 1, 100000);
+ const pageSize = parsePositiveInt(kwargs['page-size'] ?? kwargs.pageSize, 'page-size', 10, 1000);
+ const pt = firstNonEmpty(kwargs.pt, process.env.SAKY_PT, defaultPt());
+ if (!/^\d{8}$/.test(pt)) {
+ throw new CliError('ARGUMENT', 'pt must use YYYYMMDD format.', 'Pass --pt 20260409');
+ }
+ const returnTotalNum = parseBoolean(
+ kwargs['return-total-num'] ?? kwargs.returnTotalNum,
+ 'return-total-num',
+ true,
+ );
+ const appCode = resolveAppCode(kwargs);
+ const baseUrl = resolveBaseUrl(kwargs);
+
+ const params = new URLSearchParams({
+ pageNum: String(pageNum),
+ pageSize: String(pageSize),
+ pt,
+ returnTotalNum: String(returnTotalNum),
+ });
+ const url = `${baseUrl}${dataset.endpoint}?${params.toString()}`;
+
+ let response: Response;
+ try {
+ response = await fetch(url, {
+ method: 'GET',
+ headers: {
+ Authorization: `APPCODE ${appCode}`,
+ 'Content-Type': 'application/json',
+ },
+ });
+ } catch (error: unknown) {
+ throw new CliError(
+ 'FETCH_ERROR',
+ `Unable to reach SAKY API: ${getErrorMessage(error)}`,
+ 'Check your network connection, VPN, or SAKY_BASE_URL and try again.',
+ );
+ }
+
+ const rawText = await response.text();
+ let payload: SakyListResponse | string = rawText;
+ if (rawText) {
+ try {
+ payload = JSON.parse(rawText) as SakyListResponse;
+ } catch {
+ payload = rawText;
+ }
+ }
+
+ if (!response.ok) {
+ const message =
+ payload && typeof payload === 'object'
+ ? trimOrEmpty(payload.errMsg) || `HTTP ${response.status}`
+ : trimOrEmpty(payload) || `HTTP ${response.status}`;
+ throw new CliError(
+ 'API_ERROR',
+ `SAKY API request failed: ${message}`,
+ 'Check the APPCODE, base URL, and whether the requested partition exists.',
+ );
+ }
+
+ if (!payload || typeof payload !== 'object') {
+ throw new CliError(
+ 'API_ERROR',
+ 'SAKY API returned a non-JSON response.',
+ 'Check the upstream gateway or try again later.',
+ );
+ }
+
+ if (payload.errCode !== 0) {
+ const hint = payload.errCode === 1108110565
+ ? `数仓查询异常,优先检查 --pt 是否正确。文档样例日期是 ${SAKY_DOC_SAMPLE_PT}。`
+ : 'Check the APPCODE, requested partition, and upstream API status.';
+ throw new CliError(
+ 'API_ERROR',
+ `SAKY API error ${String(payload.errCode ?? 'unknown')}: ${trimOrEmpty(payload.errMsg) || 'unknown error'}`,
+ hint,
+ );
+ }
+
+ const rows = Array.isArray(payload.data?.rows) ? payload.data?.rows ?? [] : [];
+ return rows.map((row) => ({
+ ...(row as Record),
+ _requestId: payload.requestId ?? '',
+ _pageNum: payload.data?.pageNum ?? pageNum,
+ _pageSize: payload.data?.pageSize ?? pageSize,
+ _totalNum: payload.data?.totalNum ?? '',
+ }));
+}
diff --git a/clis/saky/docs.ts b/clis/saky/docs.ts
new file mode 100644
index 000000000..ac8a7d7c2
--- /dev/null
+++ b/clis/saky/docs.ts
@@ -0,0 +1,21 @@
+import { cli, Strategy } from '@jackwener/opencli/registry';
+import {
+ SAKY_DEFAULT_BASE_URL,
+ SAKY_DOC_SAMPLE_PT,
+ SAKY_DOC_TITLE,
+ SAKY_SITE,
+ sakyDocsRows,
+} from './common.js';
+
+cli({
+ site: SAKY_SITE,
+ name: 'docs',
+ description: 'Show SAKY product label API summary and command mapping',
+ strategy: Strategy.PUBLIC,
+ browser: false,
+ defaultFormat: 'table',
+ args: [],
+ columns: ['command', 'endpoint', 'description', 'sample_pt', 'key_fields'],
+ footerExtra: () => `${SAKY_DOC_TITLE} · base=${SAKY_DEFAULT_BASE_URL} · auth=APPCODE · sample_pt=${SAKY_DOC_SAMPLE_PT}`,
+ func: async () => sakyDocsRows(),
+});
diff --git a/clis/saky/electronics.ts b/clis/saky/electronics.ts
new file mode 100644
index 000000000..ee5eb2c25
--- /dev/null
+++ b/clis/saky/electronics.ts
@@ -0,0 +1,24 @@
+import { cli, Strategy } from '@jackwener/opencli/registry';
+import { buildSakyFooter, querySakyDataset, SAKY_DATASETS, SAKY_DOMAIN, SAKY_SITE } from './common.js';
+
+cli({
+ site: SAKY_SITE,
+ name: 'electronics',
+ aliases: ['elec'],
+ description: 'Query electronics product label data from SAKY warehouse API',
+ domain: SAKY_DOMAIN,
+ strategy: Strategy.PUBLIC,
+ browser: false,
+ timeoutSeconds: 30,
+ args: [
+ { name: 'page-num', type: 'int', default: 1, help: 'Page number' },
+ { name: 'page-size', type: 'int', default: 10, help: 'Rows per page' },
+ { name: 'pt', help: 'Partition date in YYYYMMDD; defaults to yesterday in Asia/Shanghai' },
+ { name: 'return-total-num', type: 'bool', default: true, help: 'Return total count metadata' },
+ { name: 'app-code', help: 'APPCODE for the API gateway; or set SAKY_APPCODE' },
+ { name: 'base-url', help: 'Override API base URL; defaults to SAKY_BASE_URL or built-in base URL' },
+ ],
+ columns: SAKY_DATASETS.electronics.columns,
+ footerExtra: buildSakyFooter,
+ func: async (_page, kwargs) => querySakyDataset('electronics', kwargs),
+});
diff --git a/clis/saky/formula.ts b/clis/saky/formula.ts
new file mode 100644
index 000000000..12e59305a
--- /dev/null
+++ b/clis/saky/formula.ts
@@ -0,0 +1,23 @@
+import { cli, Strategy } from '@jackwener/opencli/registry';
+import { buildSakyFooter, querySakyDataset, SAKY_DATASETS, SAKY_DOMAIN, SAKY_SITE } from './common.js';
+
+cli({
+ site: SAKY_SITE,
+ name: 'formula',
+ description: 'Query formula product label data from SAKY warehouse API',
+ domain: SAKY_DOMAIN,
+ strategy: Strategy.PUBLIC,
+ browser: false,
+ timeoutSeconds: 30,
+ args: [
+ { name: 'page-num', type: 'int', default: 1, help: 'Page number' },
+ { name: 'page-size', type: 'int', default: 10, help: 'Rows per page' },
+ { name: 'pt', help: 'Partition date in YYYYMMDD; defaults to yesterday in Asia/Shanghai' },
+ { name: 'return-total-num', type: 'bool', default: true, help: 'Return total count metadata' },
+ { name: 'app-code', help: 'APPCODE for the API gateway; or set SAKY_APPCODE' },
+ { name: 'base-url', help: 'Override API base URL; defaults to SAKY_BASE_URL or built-in base URL' },
+ ],
+ columns: SAKY_DATASETS.formula.columns,
+ footerExtra: buildSakyFooter,
+ func: async (_page, kwargs) => querySakyDataset('formula', kwargs),
+});
diff --git a/clis/saky/tool.ts b/clis/saky/tool.ts
new file mode 100644
index 000000000..d5955263b
--- /dev/null
+++ b/clis/saky/tool.ts
@@ -0,0 +1,23 @@
+import { cli, Strategy } from '@jackwener/opencli/registry';
+import { buildSakyFooter, querySakyDataset, SAKY_DATASETS, SAKY_DOMAIN, SAKY_SITE } from './common.js';
+
+cli({
+ site: SAKY_SITE,
+ name: 'tool',
+ description: 'Query tool product label data from SAKY warehouse API',
+ domain: SAKY_DOMAIN,
+ strategy: Strategy.PUBLIC,
+ browser: false,
+ timeoutSeconds: 30,
+ args: [
+ { name: 'page-num', type: 'int', default: 1, help: 'Page number' },
+ { name: 'page-size', type: 'int', default: 10, help: 'Rows per page' },
+ { name: 'pt', help: 'Partition date in YYYYMMDD; defaults to yesterday in Asia/Shanghai' },
+ { name: 'return-total-num', type: 'bool', default: true, help: 'Return total count metadata' },
+ { name: 'app-code', help: 'APPCODE for the API gateway; or set SAKY_APPCODE' },
+ { name: 'base-url', help: 'Override API base URL; defaults to SAKY_BASE_URL or built-in base URL' },
+ ],
+ columns: SAKY_DATASETS.tool.columns,
+ footerExtra: buildSakyFooter,
+ func: async (_page, kwargs) => querySakyDataset('tool', kwargs),
+});
diff --git a/clis/shopee/product-shopdora-download.test.ts b/clis/shopee/product-shopdora-download.test.ts
new file mode 100644
index 000000000..9046bb948
--- /dev/null
+++ b/clis/shopee/product-shopdora-download.test.ts
@@ -0,0 +1,149 @@
+import { pathToFileURL } from 'node:url';
+import { describe, expect, it, vi } from 'vitest';
+import { getRegistry } from '@jackwener/opencli/registry';
+import type { IPage } from '@jackwener/opencli/types';
+import './product-shopdora-download.js';
+
+const {
+ EXPORT_REVIEW_BUTTON_SELECTOR,
+ DETAIL_FILTER_INPUT_SELECTOR,
+ SECONDARY_FILTER_INPUT_SELECTOR,
+ CONFIRM_EXPORT_BUTTON_SELECTOR,
+ normalizeShopeeReviewUrl,
+ bindShopeeProductTab,
+ ensureShopeeProductPage,
+ buildEnsureCheckboxStateScript,
+ buildWaitForExportReviewReadyScript,
+} =
+ await import('./product-shopdora-download.js').then((m) => (m as typeof import('./product-shopdora-download.js')).__test__);
+
+describe('shopee product-shopdora-download adapter', () => {
+ const command = getRegistry().get('shopee/product-shopdora-download');
+
+ it('registers the command with correct shape', () => {
+ expect(command).toBeDefined();
+ expect(command!.site).toBe('shopee');
+ expect(command!.name).toBe('product-shopdora-download');
+ expect(command!.domain).toBe('shopee.sg');
+ expect(command!.strategy).toBe('cookie');
+ expect(command!.navigateBefore).toBe(false);
+ expect(command!.columns).toEqual(['status', 'message', 'local_url', 'local_path', 'product_url']);
+ expect(typeof command!.func).toBe('function');
+ });
+
+ it('has url as a required positional arg', () => {
+ const urlArg = command!.args.find((arg) => arg.name === 'url');
+ expect(urlArg).toBeDefined();
+ expect(urlArg!.required).toBe(true);
+ expect(urlArg!.positional).toBe(true);
+ });
+
+ it('normalizes product urls', () => {
+ expect(normalizeShopeeReviewUrl('https://shopee.sg/item')).toBe('https://shopee.sg/item');
+ expect(() => normalizeShopeeReviewUrl('')).toThrow('A Shopee product URL is required.');
+ expect(() => normalizeShopeeReviewUrl('not-a-url')).toThrow('Shopee product-shopdora-download requires a valid absolute product URL.');
+ });
+
+ it('builds DOM scripts around the recorded export workflow', () => {
+ expect(buildEnsureCheckboxStateScript(DETAIL_FILTER_INPUT_SELECTOR, true)).toContain(DETAIL_FILTER_INPUT_SELECTOR);
+ expect(buildEnsureCheckboxStateScript(SECONDARY_FILTER_INPUT_SELECTOR, false)).toContain('checkbox_not_found');
+ expect(buildWaitForExportReviewReadyScript(30000, 1000)).toContain('.putButton .common-btn.en_common-btn');
+ expect(buildWaitForExportReviewReadyScript(30000, 1000)).toContain('Export Review');
+ });
+
+ it('binds to the matching existing browser tab using the shopee workspace', async () => {
+ const bindFn = vi.fn(async () => ({ tabId: 2 }));
+
+ await expect(
+ bindShopeeProductTab(
+ 'https://shopee.sg/Jeep-EW121-True-Wireless-Bluetooth-5.4-Earbuds-i.1058254930.25483790400',
+ bindFn,
+ ),
+ ).resolves.toBe(true);
+
+ expect(bindFn).toHaveBeenCalledWith('site:shopee', {
+ matchUrl: 'https://shopee.sg/Jeep-EW121-True-Wireless-Bluetooth-5.4-Earbuds-i.1058254930.25483790400',
+ });
+ });
+
+ it('reuses the matched tab, clears localStorage, and reloads the product page', async () => {
+ const page = {
+ goto: vi.fn(async () => {}),
+ evaluate: vi.fn(async () => ({ ok: true, host: 'shopee.sg' })),
+ } as unknown as IPage;
+ const bindFn = vi.fn(async () => ({ tabId: 2 }));
+
+ await expect(
+ ensureShopeeProductPage(page, 'https://shopee.sg/product-i.1.2', bindFn),
+ ).resolves.toBe(true);
+
+ expect(page.goto).toHaveBeenNthCalledWith(1, 'https://shopee.sg', { waitUntil: 'load' });
+ expect(page.evaluate).toHaveBeenCalledWith(expect.stringContaining('localStorage.clear()'));
+ expect(page.goto).toHaveBeenNthCalledWith(2, 'https://shopee.sg/product-i.1.2', { waitUntil: 'load' });
+ });
+
+ it('navigates, downloads the file, and returns the local file url', async () => {
+ const downloadedFile = '/tmp/opencli-shopee-product-shopdora-download-test/reviews.csv';
+ const goto = vi.fn>().mockResolvedValue(undefined);
+ const wait = vi.fn>().mockResolvedValue(undefined);
+ const click = vi.fn>().mockResolvedValue(undefined);
+ const scroll = vi.fn>().mockResolvedValue(undefined);
+ const evaluate = vi.fn>()
+ .mockResolvedValueOnce({ ok: true, host: 'shopee.sg' })
+ .mockResolvedValue({ ok: true, text: 'Export Review' });
+ const waitForDownload = vi.fn>>()
+ .mockResolvedValue({ filename: downloadedFile });
+
+ const page = { goto, wait, click, scroll, evaluate, waitForDownload } as unknown as IPage;
+
+ const result = await command!.func!(page, {
+ url: 'https://shopee.sg/Jeep-EW121-True-Wireless-Bluetooth-5.4-Earbuds-i.1058254930.25483790400',
+ });
+
+ expect(goto).toHaveBeenNthCalledWith(1, 'https://shopee.sg', { waitUntil: 'load' });
+ expect(goto).toHaveBeenNthCalledWith(
+ 2,
+ 'https://shopee.sg/Jeep-EW121-True-Wireless-Bluetooth-5.4-Earbuds-i.1058254930.25483790400',
+ { waitUntil: 'load' },
+ );
+ expect(evaluate).toHaveBeenNthCalledWith(1, expect.stringContaining('localStorage.clear()'));
+ expect(wait).toHaveBeenCalledWith({ selector: EXPORT_REVIEW_BUTTON_SELECTOR, timeout: 15 });
+ expect(click).toHaveBeenCalledWith(EXPORT_REVIEW_BUTTON_SELECTOR);
+ expect(click).toHaveBeenCalledWith(CONFIRM_EXPORT_BUTTON_SELECTOR);
+ expect(scroll).toHaveBeenCalled();
+ expect(evaluate).toHaveBeenCalledWith(expect.stringContaining(EXPORT_REVIEW_BUTTON_SELECTOR));
+ expect(evaluate).toHaveBeenCalledWith(expect.stringContaining(DETAIL_FILTER_INPUT_SELECTOR));
+ expect(evaluate).toHaveBeenCalledWith(expect.stringContaining(SECONDARY_FILTER_INPUT_SELECTOR));
+ expect(evaluate).toHaveBeenCalledWith(expect.stringContaining(CONFIRM_EXPORT_BUTTON_SELECTOR));
+ expect(evaluate).toHaveBeenCalledWith(expect.stringContaining('.putButton .common-btn.en_common-btn'));
+ expect(waitForDownload).toHaveBeenCalledWith({
+ startedAfterMs: expect.any(Number),
+ timeoutMs: 30000,
+ });
+ expect(result).toEqual([{
+ status: 'success',
+ message: 'Downloaded Shopee product Shopdora export with the recorded good-detail filter.',
+ local_url: pathToFileURL(downloadedFile).href,
+ local_path: downloadedFile,
+ product_url: 'https://shopee.sg/Jeep-EW121-True-Wireless-Bluetooth-5.4-Earbuds-i.1058254930.25483790400',
+ }]);
+ });
+
+ it('falls back to clearing the target host and reopening the product page when no existing product tab is found', async () => {
+ const page = {
+ goto: vi.fn(async () => {}),
+ evaluate: vi.fn(async () => ({ ok: true, host: 'shopee.sg' })),
+ } as unknown as IPage;
+ const bindFn = vi.fn(async () => {
+ throw new Error('not found');
+ });
+
+ await expect(
+ ensureShopeeProductPage(page, 'https://shopee.sg/product-i.1.2', bindFn),
+ ).resolves.toBe(false);
+
+ expect(page.goto).toHaveBeenNthCalledWith(1, 'https://shopee.sg', { waitUntil: 'load' });
+ expect(page.evaluate).toHaveBeenCalledWith(expect.stringContaining('localStorage.clear()'));
+ expect(page.goto).toHaveBeenNthCalledWith(2, 'https://shopee.sg/product-i.1.2', { waitUntil: 'load' });
+ });
+});
diff --git a/clis/shopee/product-shopdora-download.ts b/clis/shopee/product-shopdora-download.ts
new file mode 100644
index 000000000..6aedd1c45
--- /dev/null
+++ b/clis/shopee/product-shopdora-download.ts
@@ -0,0 +1,313 @@
+import { pathToFileURL } from 'node:url';
+import {
+ ArgumentError,
+ CommandExecutionError,
+ getErrorMessage,
+} from '@jackwener/opencli/errors';
+import { bindCurrentTab } from '@jackwener/opencli/browser/daemon-client';
+import { cli, Strategy } from '@jackwener/opencli/registry';
+import type { IPage } from '@jackwener/opencli/types';
+import { clearLocalStorageForUrlHost, simulateHumanBehavior, waitRandomDuration } from './shared.js';
+
+const EXPORT_REVIEW_BUTTON_SELECTOR =
+ 'div > div:nth-of-type(1) > div:nth-of-type(2) > div > div.common-btn.en_common-btn';
+const DETAIL_FILTER_LABEL_SELECTOR =
+ 'div > div:nth-of-type(4) > div:nth-of-type(2) > label > span.t-checkbox__input:nth-of-type(1)';
+const DETAIL_FILTER_INPUT_SELECTOR =
+ 'div > div:nth-of-type(4) > div:nth-of-type(2) > label > input.t-checkbox__former';
+const SECONDARY_FILTER_LABEL_SELECTOR =
+ 'div:nth-of-type(1) > div:nth-of-type(2) > span:nth-of-type(2) > label > span.t-checkbox__input:nth-of-type(1)';
+const SECONDARY_FILTER_INPUT_SELECTOR =
+ 'div:nth-of-type(1) > div:nth-of-type(2) > span:nth-of-type(2) > label > input.t-checkbox__former';
+const CONFIRM_EXPORT_BUTTON_SELECTOR =
+ 'div > div:nth-of-type(5) > div:nth-of-type(2) > button:nth-of-type(2)';
+
+const SHOPEE_WORKSPACE = 'site:shopee';
+
+type BindCurrentTabFn = (
+ workspace: string,
+ opts?: { matchDomain?: string; matchPathPrefix?: string; matchUrl?: string },
+) => Promise;
+
+function normalizeShopeeReviewUrl(value: unknown): string {
+ const raw = String(value ?? '').trim();
+ if (!raw) {
+ throw new ArgumentError('A Shopee product URL is required.');
+ }
+
+ let parsed: URL;
+ try {
+ parsed = new URL(raw);
+ } catch {
+ throw new ArgumentError('Shopee product-shopdora-download requires a valid absolute product URL.');
+ }
+
+ if (!/^https?:$/.test(parsed.protocol)) {
+ throw new ArgumentError('Shopee product-shopdora-download only supports http(s) product URLs.');
+ }
+
+ return parsed.toString();
+}
+
+function buildEnsureCheckboxStateScript(selector: string, checked: boolean): string {
+ return `
+ (() => {
+ const input = document.querySelector(${JSON.stringify(selector)});
+ if (!(input instanceof HTMLInputElement)) {
+ return { ok: false, error: 'checkbox_not_found' };
+ }
+
+ if (input.checked === ${checked ? 'true' : 'false'}) {
+ return { ok: true, changed: false, checked: input.checked };
+ }
+
+ const label = input.closest('label');
+ const clickable = label?.querySelector('span.t-checkbox__input') || label || input;
+
+ if (!(clickable instanceof HTMLElement)) {
+ return { ok: false, error: 'checkbox_click_target_not_found' };
+ }
+
+ clickable.click();
+
+ return {
+ ok: input.checked === ${checked ? 'true' : 'false'},
+ changed: true,
+ checked: input.checked,
+ };
+ })()
+ `;
+}
+
+async function bindShopeeProductTab(
+ productUrl: string,
+ bindFn: BindCurrentTabFn = bindCurrentTab,
+): Promise {
+ try {
+ await bindFn(SHOPEE_WORKSPACE, { matchUrl: productUrl });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function ensureShopeeProductPage(
+ page: IPage,
+ productUrl: string,
+ bindFn: BindCurrentTabFn = bindCurrentTab,
+): Promise {
+ const reusedExistingTab = await bindShopeeProductTab(productUrl, bindFn);
+ // await clearLocalStorageForUrlHost(page, productUrl);
+ await page.goto(productUrl, { waitUntil: 'load' });
+ return reusedExistingTab;
+}
+
+function buildWaitForExportReviewReadyScript(timeoutMs: number, pollIntervalMs: number): string {
+ return `
+ new Promise((resolve, reject) => {
+ const timeout = ${timeoutMs};
+ const pollInterval = ${pollIntervalMs};
+ const selector = '.putButton .common-btn.en_common-btn';
+ const normalizeText = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
+ const startedAt = Date.now();
+ let lastKnownText = '';
+
+ const readButtonState = () => {
+ const targets = Array.from(document.querySelectorAll(selector));
+ const target =
+ targets.find((element) => {
+ const directText = Array.from(element.childNodes)
+ .filter((node) => node.nodeType === Node.TEXT_NODE)
+ .map((node) => node.textContent || '')
+ .join(' ');
+ return normalizeText(directText).includes('Export Review');
+ }) || targets[0] || null;
+
+ if (!target) return { found: false, text: '', done: false };
+
+ const buttonLabel = normalizeText(
+ Array.from(target.childNodes)
+ .filter((node) => node.nodeType === Node.TEXT_NODE)
+ .map((node) => node.textContent || '')
+ .join(' '),
+ );
+
+ return {
+ found: true,
+ text: buttonLabel,
+ done: buttonLabel === 'Export Review',
+ };
+ };
+
+ const tick = () => {
+ const state = readButtonState();
+ if (state.done) {
+ resolve({ ok: true, text: state.text || 'Export Review' });
+ return;
+ }
+
+ if (state.found) {
+ lastKnownText = state.text || '';
+ }
+
+ if (Date.now() - startedAt >= timeout) {
+ reject(new Error(
+ 'Timed out waiting for Export Review button text to reset. Last text: '
+ + (lastKnownText || 'unknown'),
+ ));
+ return;
+ }
+
+ setTimeout(tick, pollInterval);
+ };
+
+ setTimeout(tick, 2000);
+ })
+ `;
+}
+
+async function ensureCheckboxState(page: IPage, selector: string, checked: boolean, label: string): Promise {
+ const result = await page.evaluate(buildEnsureCheckboxStateScript(selector, checked));
+ if (!result || typeof result !== 'object' || !(result as { ok?: boolean }).ok) {
+ throw new CommandExecutionError(`Shopee product-shopdora-download could not ${checked ? 'enable' : 'disable'} ${label}`);
+ }
+}
+
+async function waitForExportReviewReady(page: IPage, timeoutMs = 30000, pollIntervalMs = 1000): Promise {
+ await page.evaluate(buildWaitForExportReviewReadyScript(timeoutMs, pollIntervalMs));
+}
+
+async function clickSelector(page: IPage, selector: string, label: string): Promise {
+ try {
+ await page.click(selector);
+ } catch (error) {
+ throw new CommandExecutionError(
+ `Shopee product-shopdora-download could not click ${label}`,
+ getErrorMessage(error),
+ );
+ }
+}
+
+async function applyCheckboxStep(
+ page: IPage,
+ labelSelector: string,
+ inputSelector: string,
+ checked: boolean,
+ label: string,
+): Promise {
+ await page.wait({ selector: inputSelector, timeout: 10 });
+ await simulateHumanBehavior(page, {
+ selector: labelSelector,
+ scrollRangePx: [30, 120],
+ preWaitRangeMs: [250, 700],
+ postWaitRangeMs: [150, 450],
+ });
+ await clickSelector(page, labelSelector, `${label} label`);
+ await waitRandomDuration(page, [250, 650]);
+ await ensureCheckboxState(page, inputSelector, checked, label);
+ await waitRandomDuration(page, [250, 700]);
+}
+
+cli({
+ site: 'shopee',
+ name: 'product-shopdora-download',
+ description: 'Export Shopee product Shopdora data with the recorded good-detail review workflow',
+ domain: 'shopee.sg',
+ strategy: Strategy.COOKIE,
+ navigateBefore: false,
+ args: [
+ {
+ name: 'url',
+ positional: true,
+ required: true,
+ help: 'Shopee product URL, e.g. https://shopee.sg/...-i.123.456',
+ },
+ ],
+ columns: ['status', 'message', 'local_url', 'local_path', 'product_url'],
+ func: async (page, args) => {
+ if (!page) {
+ throw new CommandExecutionError(
+ 'Browser session required for shopee product-shopdora-download',
+ 'Run the command with the browser bridge connected',
+ );
+ }
+
+ const productUrl = normalizeShopeeReviewUrl(args.url);
+ if (typeof page.waitForDownload !== 'function') {
+ throw new CommandExecutionError(
+ 'Shopee product-shopdora-download requires browser download tracking support',
+ 'Reload the browser bridge extension/plugin to a build that supports download-wait.',
+ );
+ }
+
+ await ensureShopeeProductPage(page, productUrl);
+ await page.wait({ selector: EXPORT_REVIEW_BUTTON_SELECTOR, timeout: 15 });
+ await simulateHumanBehavior(page, {
+ selector: EXPORT_REVIEW_BUTTON_SELECTOR,
+ scrollRangePx: [60, 180],
+ preWaitRangeMs: [500, 1200],
+ postWaitRangeMs: [300, 800],
+ allowReverseScroll: false,
+ });
+
+ await clickSelector(page, EXPORT_REVIEW_BUTTON_SELECTOR, 'Export Review');
+ await waitRandomDuration(page, [900, 1600]);
+
+ await applyCheckboxStep(
+ page,
+ DETAIL_FILTER_LABEL_SELECTOR,
+ DETAIL_FILTER_INPUT_SELECTOR,
+ true,
+ 'detail filter',
+ );
+ await applyCheckboxStep(
+ page,
+ SECONDARY_FILTER_LABEL_SELECTOR,
+ SECONDARY_FILTER_INPUT_SELECTOR,
+ false,
+ 'secondary filter',
+ );
+
+ await page.wait({ selector: CONFIRM_EXPORT_BUTTON_SELECTOR, timeout: 10 });
+ await simulateHumanBehavior(page, {
+ selector: CONFIRM_EXPORT_BUTTON_SELECTOR,
+ scrollRangePx: [20, 100],
+ preWaitRangeMs: [250, 700],
+ postWaitRangeMs: [200, 500],
+ });
+ const downloadStartedAtMs = Date.now();
+ await clickSelector(page, CONFIRM_EXPORT_BUTTON_SELECTOR, 'export confirm button');
+ await waitForExportReviewReady(page);
+
+ const download = await page.waitForDownload({
+ startedAfterMs: downloadStartedAtMs,
+ timeoutMs: 30000,
+ });
+ const localPath = String(download?.filename ?? '').trim();
+ if (!localPath) {
+ throw new CommandExecutionError('Shopee product-shopdora-download finished without a local filename');
+ }
+
+ return [{
+ status: 'success',
+ message: 'Downloaded Shopee product Shopdora export with the recorded good-detail filter.',
+ local_url: pathToFileURL(localPath).href,
+ local_path: localPath,
+ product_url: productUrl,
+ }];
+ },
+});
+
+export const __test__ = {
+ EXPORT_REVIEW_BUTTON_SELECTOR,
+ DETAIL_FILTER_LABEL_SELECTOR,
+ DETAIL_FILTER_INPUT_SELECTOR,
+ SECONDARY_FILTER_LABEL_SELECTOR,
+ SECONDARY_FILTER_INPUT_SELECTOR,
+ CONFIRM_EXPORT_BUTTON_SELECTOR,
+ normalizeShopeeReviewUrl,
+ bindShopeeProductTab,
+ ensureShopeeProductPage,
+ buildEnsureCheckboxStateScript,
+ buildWaitForExportReviewReadyScript,
+};
diff --git a/clis/shopee/product.test.ts b/clis/shopee/product.test.ts
new file mode 100644
index 000000000..943445303
--- /dev/null
+++ b/clis/shopee/product.test.ts
@@ -0,0 +1,201 @@
+import { describe, expect, it, vi } from 'vitest';
+import { getRegistry } from '@jackwener/opencli/registry';
+import './product.js';
+
+const {
+ PRODUCT_COLUMNS,
+ PRODUCT_FIELDS,
+ mergeProductDetails,
+ hasMeaningfulProductData,
+ firstUrlFromSrcset,
+ pickImageUrlFromAttributes,
+ bindShopeeProductTab,
+ ensureShopeeProductPage,
+} =
+ await import('./product.js').then((m) => (m as typeof import('./product.js')).__test__);
+
+describe('shopee product adapter', () => {
+ const command = getRegistry().get('shopee/product');
+
+ it('registers the command with correct shape', () => {
+ expect(command).toBeDefined();
+ expect(command!.site).toBe('shopee');
+ expect(command!.name).toBe('product');
+ expect(command!.domain).toBe('shopee.sg');
+ expect(command!.strategy).toBe('cookie');
+ expect(command!.navigateBefore).toBe(false);
+ expect(typeof command!.func).toBe('function');
+ });
+
+ it('has url as a required positional arg', () => {
+ const urlArg = command!.args.find((arg) => arg.name === 'url');
+ expect(urlArg).toBeDefined();
+ expect(urlArg!.required).toBe(true);
+ expect(urlArg!.positional).toBe(true);
+ });
+
+ it('includes key product fields in the output columns', () => {
+ expect(PRODUCT_COLUMNS).toEqual(
+ expect.arrayContaining([
+ 'product_url',
+ 'title',
+ 'rating_score',
+ 'current_price_range',
+ 'shopee_price',
+ 'shopdora_price',
+ 'main_image_url',
+ 'video_url',
+ 'thumbnail_url',
+ 'attr_options',
+ 'spec_options',
+ 'seller_name',
+ 'shop_name',
+ 'shop_url',
+ 'shop_product_list_url',
+ 'stock',
+ ]),
+ );
+ expect(command!.columns).toEqual(expect.arrayContaining(PRODUCT_COLUMNS));
+ });
+
+ it('marks structured template fields with list metadata', () => {
+ const videoField = PRODUCT_FIELDS.find((field) => field.name === 'video_url');
+ const thumbnailField = PRODUCT_FIELDS.find((field) => field.name === 'thumbnail_url');
+ const attrOptionsField = PRODUCT_FIELDS.find((field) => field.name === 'attr_options');
+ const specOptionsField = PRODUCT_FIELDS.find((field) => field.name === 'spec_options');
+
+ expect(videoField).toMatchObject({
+ type: 'list',
+ fields: [
+ { name: 'video_url', type: 'attribute', attribute: 'src', transform: 'absolute_url' },
+ ],
+ });
+ expect(thumbnailField).toMatchObject({
+ type: 'list',
+ fields: [{ name: 'thumbnail_url', type: 'attribute', attribute: 'src', transform: 'image_src' }],
+ });
+ expect(attrOptionsField).toMatchObject({
+ type: 'list',
+ fields: expect.arrayContaining([
+ expect.objectContaining({ name: 'title', type: 'text' }),
+ expect.objectContaining({ name: 'image_url', type: 'attribute', attribute: 'src', transform: 'image_src' }),
+ expect.objectContaining({ name: 'is_selected', transform: 'selected_class' }),
+ ]),
+ });
+ expect(specOptionsField).toMatchObject({
+ type: 'list',
+ fields: expect.arrayContaining([
+ expect.objectContaining({ name: 'title', type: 'text' }),
+ expect.objectContaining({ name: 'is_selected', transform: 'selected_class' }),
+ ]),
+ });
+ });
+});
+
+describe('shopee attr option image helpers', () => {
+ it('parses the first url from srcset values', () => {
+ expect(
+ firstUrlFromSrcset(
+ 'https://down-sg.img.susercontent.com/file/sg-11134207-7rdwc-mcj4nu2ezjl22d@resize_w24_nl.webp 1x, https://down-sg.img.susercontent.com/file/sg-11134207-7rdwc-mcj4nu2ezjl22d@resize_w48_nl.webp 2x',
+ ),
+ ).toBe('https://down-sg.img.susercontent.com/file/sg-11134207-7rdwc-mcj4nu2ezjl22d@resize_w24_nl.webp');
+ });
+
+ it('prefers the direct img src for shopee picture button attrs', () => {
+ expect(
+ pickImageUrlFromAttributes({
+ src: 'https://down-sg.img.susercontent.com/file/sg-11134207-7rdwc-mcj4nu2ezjl22d',
+ srcset:
+ 'https://down-sg.img.susercontent.com/file/sg-11134207-7rdwc-mcj4nu2ezjl22d@resize_w24_nl 1x, https://down-sg.img.susercontent.com/file/sg-11134207-7rdwc-mcj4nu2ezjl22d@resize_w48_nl 2x',
+ }),
+ ).toBe('https://down-sg.img.susercontent.com/file/sg-11134207-7rdwc-mcj4nu2ezjl22d');
+ });
+});
+
+describe('mergeProductDetails', () => {
+ it('fills only missing fields from a later extraction pass', () => {
+ expect(
+ mergeProductDetails(
+ { title: 'Product A', seller_name: '', stock: '' },
+ { title: 'Product B', seller_name: 'Shop 1', stock: '99' },
+ ),
+ ).toEqual({
+ title: 'Product A',
+ seller_name: 'Shop 1',
+ stock: '99',
+ });
+ });
+});
+
+describe('hasMeaningfulProductData', () => {
+ it('returns false for empty extraction rows', () => {
+ expect(hasMeaningfulProductData({ title: '', seller_name: '' })).toBe(false);
+ });
+
+ it('returns true once any mapped product field has content', () => {
+ expect(hasMeaningfulProductData({ title: 'Wireless Earbuds' })).toBe(true);
+ });
+});
+
+describe('bindShopeeProductTab', () => {
+ it('binds to the matching existing browser tab using the shopee workspace', async () => {
+ const bindFn = vi.fn(async () => ({ tabId: 2 }));
+
+ await expect(
+ bindShopeeProductTab(
+ 'https://shopee.sg/Jeep-EW121-True-Wireless-Bluetooth-5.4-Earbuds-i.1058254930.25483790400',
+ bindFn,
+ ),
+ ).resolves.toBe(true);
+
+ expect(bindFn).toHaveBeenCalledWith('site:shopee', {
+ matchUrl: 'https://shopee.sg/Jeep-EW121-True-Wireless-Bluetooth-5.4-Earbuds-i.1058254930.25483790400',
+ });
+ });
+
+ it('returns false when no existing browser tab matches the product url', async () => {
+ const bindFn = vi.fn(async () => {
+ throw new Error('No visible tab matching target');
+ });
+
+ await expect(
+ bindShopeeProductTab('https://shopee.sg/product-i.1.2', bindFn),
+ ).resolves.toBe(false);
+ });
+});
+
+describe('ensureShopeeProductPage', () => {
+ it('reuses the matched tab, clears localStorage, and reloads the product page', async () => {
+ const page = {
+ goto: vi.fn(async () => {}),
+ evaluate: vi.fn(async () => ({ ok: true, host: 'shopee.sg' })),
+ } as unknown as import('@jackwener/opencli/types').IPage;
+ const bindFn = vi.fn(async () => ({ tabId: 2 }));
+
+ await expect(
+ ensureShopeeProductPage(page, 'https://shopee.sg/product-i.1.2', bindFn),
+ ).resolves.toBe(true);
+
+ expect(page.goto).toHaveBeenNthCalledWith(1, 'https://shopee.sg', { waitUntil: 'load' });
+ expect(page.evaluate).toHaveBeenCalledWith(expect.stringContaining('localStorage.clear()'));
+ expect(page.goto).toHaveBeenNthCalledWith(2, 'https://shopee.sg/product-i.1.2', { waitUntil: 'load' });
+ });
+
+ it('falls back to clearing the target host and opening the product url when no existing tab is found', async () => {
+ const page = {
+ goto: vi.fn(async () => {}),
+ evaluate: vi.fn(async () => ({ ok: true, host: 'shopee.sg' })),
+ } as unknown as import('@jackwener/opencli/types').IPage;
+ const bindFn = vi.fn(async () => {
+ throw new Error('not found');
+ });
+
+ await expect(
+ ensureShopeeProductPage(page, 'https://shopee.sg/product-i.1.2', bindFn),
+ ).resolves.toBe(false);
+
+ expect(page.goto).toHaveBeenNthCalledWith(1, 'https://shopee.sg', { waitUntil: 'load' });
+ expect(page.evaluate).toHaveBeenCalledWith(expect.stringContaining('localStorage.clear()'));
+ expect(page.goto).toHaveBeenNthCalledWith(2, 'https://shopee.sg/product-i.1.2', { waitUntil: 'load' });
+ });
+});
diff --git a/clis/shopee/product.ts b/clis/shopee/product.ts
new file mode 100644
index 000000000..70cedaaaa
--- /dev/null
+++ b/clis/shopee/product.ts
@@ -0,0 +1,456 @@
+import {
+ CommandExecutionError,
+ EmptyResultError,
+} from '@jackwener/opencli/errors';
+import { bindCurrentTab } from '@jackwener/opencli/browser/daemon-client';
+import { cli, Strategy } from '@jackwener/opencli/registry';
+import type { IPage } from '@jackwener/opencli/types';
+import { clearLocalStorageForUrlHost, simulateHumanBehavior } from './shared.js';
+
+type ShopeeField = {
+ name: string;
+ selector: string;
+ type?: 'text' | 'attribute' | 'list';
+ attribute?: string;
+ fields?: ShopeeField[];
+ transform?: 'absolute_url' | 'selected_class' | 'image_src';
+};
+
+const DIRECT_IMAGE_ATTRIBUTES = [
+ 'src',
+ 'data-src',
+ 'data-lazy-src',
+ 'data-original',
+ 'data-img-src',
+] as const;
+
+const SRCSET_IMAGE_ATTRIBUTES = [
+ 'srcset',
+ 'data-srcset',
+ 'data-lazy-srcset',
+] as const;
+
+function firstUrlFromSrcset(value: unknown): string {
+ const raw = String(value ?? '').trim();
+ if (!raw) return '';
+ const candidate = raw
+ .split(',')
+ .map((part) => part.trim())
+ .find(Boolean);
+ if (!candidate) return '';
+ return candidate.split(/\s+/)[0]?.trim() ?? '';
+}
+
+function pickImageUrlFromAttributes(
+ attributes: Partial>,
+): string {
+ for (const key of DIRECT_IMAGE_ATTRIBUTES) {
+ const value = String(attributes[key] ?? '').trim();
+ if (value) return value;
+ }
+
+ for (const key of SRCSET_IMAGE_ATTRIBUTES) {
+ const value = firstUrlFromSrcset(attributes[key]);
+ if (value) return value;
+ }
+
+ return '';
+}
+
+const PRODUCT_FIELDS: ShopeeField[] = [
+ { name: 'title', selector: 'h1.vR6K3w > span' },
+ { name: 'rating_score', selector: 'div.F9RHbS.dQEiAI' },
+ { name: 'rating_count_text', selector: 'button.flex.e2p50f:nth-of-type(2) > .F9RHbS' },
+ { name: 'sold_count_text', selector: '.aleSBU > .AcmPRb' },
+ { name: 'current_price_range', selector: '.shopdoraPirceList span' },
+ { name: 'shopee_price', selector: '.jRlVo0 .IZPeQz.B67UQ0' },
+ { name: 'shopdora_price', selector: '.shopdoraPirceList span' },
+ { name: 'original_price', selector: '.ZA5sW5' },
+ { name: 'discount_percentage', selector: '.vms4_3' },
+ {
+ name: 'main_image_url',
+ selector: '.xxW0BG .HJ5l1F .center.Oj2Oo7 > img.rWN4DK, .xxW0BG .HJ5l1F .center.Oj2Oo7 > img, .UdI7e2 picture img.fMm3P2, .UdI7e2 picture img',
+ type: 'attribute',
+ attribute: 'src',
+ transform: 'image_src',
+ },
+ {
+ name: 'video_url',
+ selector: '.xxW0BG .HJ5l1F .center.Oj2Oo7 video source[src], .xxW0BG .HJ5l1F .center.Oj2Oo7 video[src], .UdI7e2 video source[src], .UdI7e2 video[src], .airUhU .UBG7wZ .YM40Nc video source[src], .airUhU .UBG7wZ .YM40Nc video[src]',
+ type: 'list',
+ fields: [
+ {
+ name: 'video_url',
+ selector: '',
+ type: 'attribute',
+ attribute: 'src',
+ transform: 'absolute_url',
+ },
+ ],
+ },
+ {
+ name: 'thumbnail_url',
+ selector: '.airUhU .UBG7wZ .YM40Nc picture img.raRnQV, .airUhU .UBG7wZ .YM40Nc picture img',
+ type: 'list',
+ fields: [
+ {
+ name: 'thumbnail_url',
+ selector: '',
+ type: 'attribute',
+ attribute: 'src',
+ transform: 'image_src',
+ },
+ ],
+ },
+ { name: 'first_variant_name', selector: '.j7HL5Q button:first-of-type span.ZivAAW' },
+ {
+ name: 'first_variant_image_url',
+ selector: '.j7HL5Q button:first-of-type picture, .j7HL5Q button:first-of-type img',
+ type: 'attribute',
+ attribute: 'src',
+ transform: 'image_src',
+ },
+ {
+ name: 'attr_options',
+ selector: '.j7HL5Q button:has(img)',
+ type: 'list',
+ fields: [
+ { name: 'name', selector: '.ZivAAW', type: 'text' },
+ { name: 'title', selector: '.ZivAAW', type: 'text' },
+ { name: 'label', selector: '', type: 'attribute', attribute: 'aria-label' },
+ { name: 'image_url', selector: 'picture, img', type: 'attribute', attribute: 'src', transform: 'image_src' },
+ { name: 'is_disabled', selector: '', type: 'attribute', attribute: 'aria-disabled' },
+ {
+ name: 'is_selected',
+ selector: '',
+ type: 'attribute',
+ attribute: 'class',
+ transform: 'selected_class',
+ },
+ ],
+ },
+ {
+ name: 'spec_options',
+ selector: '.j7HL5Q button:not(:has(img))',
+ type: 'list',
+ fields: [
+ { name: 'name', selector: '.ZivAAW', type: 'text' },
+ { name: 'title', selector: '.ZivAAW', type: 'text' },
+ { name: 'label', selector: '', type: 'attribute', attribute: 'aria-label' },
+ { name: 'is_disabled', selector: '', type: 'attribute', attribute: 'aria-disabled' },
+ {
+ name: 'is_selected',
+ selector: '',
+ type: 'attribute',
+ attribute: 'class',
+ transform: 'selected_class',
+ },
+ ],
+ },
+ { name: 'first_sku_price', selector: '.t-table__body tr:first-child td:nth-child(2) p' },
+ {
+ name: 'product_title',
+ selector: '.detail-info > .detail-info-list:nth-child(1) > .detail-info-item:nth-child(2) .detail-info-item-main, .detail-info > .detail-info-list:nth-child(1) > .detail-info-item:nth-child(2) .item-main.cursor, .detail-info > .detail-info-list:nth-child(1) > .detail-info-item:nth-child(2) .item-main',
+ },
+
+ { name: 'product_id', selector: '.detail-info-list:nth-of-type(1) .detail-info-item:nth-of-type(1) .item-main' },
+ { name: 'seller_name', selector: '.detail-info-list:nth-of-type(1) .detail-info-item:nth-of-type(2) .item-main' },
+ { name: 'seller_source', selector: '.detail-info-list:nth-of-type(1) .detail-info-item:nth-of-type(2) .sellerSourceTips' },
+ { name: 'brand_name', selector: '.detail-info-list:nth-of-type(1) .detail-info-item:nth-of-type(3) .item-main' },
+ { name: 'category', selector: '.detail-info-list:nth-of-type(2) .detail-info-item:nth-of-type(1) .item-main' },
+ { name: 'category_sales_rank', selector: '.detail-info-list:nth-of-type(2) .detail-info-item:nth-of-type(1) .tem-main' },
+
+ { name: 'listing_date', selector: '.detail-info-list:nth-of-type(2) .detail-info-item:nth-of-type(2) .item-main' },
+ {
+ name: 'sales_1d_7d',
+ selector: '.detail-info > .detail-info-list:nth-child(4) > .detail-info-item:nth-child(1) .detail-info-item-main, .detail-info > .detail-info-list:nth-child(4) > .detail-info-item:nth-child(1) .item-main',
+ },
+ { name: 'sales_growth_30d', selector: '.detail-info-list:nth-of-type(3) .detail-info-item:nth-of-type(2) .item-main' },
+ { name: 'sales_30d', selector: '.detail-info-list:nth-of-type(4) .detail-info-item:nth-of-type(1) .item-main' },
+ { name: 'gmv_30d', selector: '.detail-info-list:nth-of-type(4) .detail-info-item:nth-of-type(2) .item-main' },
+ { name: 'total_sales', selector: '.detail-info-list:nth-of-type(5) .detail-info-item:nth-of-type(1) .item-main' },
+ { name: 'total_gmv', selector: '.detail-info-list:nth-of-type(5) .detail-info-item:nth-of-type(2) .item-main' },
+ { name: 'stock', selector: '.detail-info-list:nth-of-type(6) .item-main' },
+ { name: 'shop_name', selector: '#sll2-pdp-product-shop .fV3TIn' },
+ {
+ name: 'shop_url',
+ selector: '#sll2-pdp-product-shop a.lG5Xxv',
+ type: 'attribute',
+ attribute: 'href',
+ transform: 'absolute_url',
+ },
+ {
+ name: 'shop_logo_url',
+ selector: '#sll2-pdp-product-shop .uLQaPg picture img.Qm507c, #sll2-pdp-product-shop .uLQaPg picture img',
+ type: 'attribute',
+ attribute: 'src',
+ transform: 'image_src',
+ },
+ { name: 'shop_last_active', selector: '#sll2-pdp-product-shop .mMlpiZ .Fsv0YO' },
+ { name: 'shop_rating_count', selector: '#sll2-pdp-product-shop .NGzCXN > :nth-child(1) .Cs6w3G' },
+ { name: 'shop_chat_response_rate', selector: '#sll2-pdp-product-shop .NGzCXN > :nth-child(2) .Cs6w3G' },
+ { name: 'shop_joined_time', selector: '#sll2-pdp-product-shop .NGzCXN > :nth-child(3) .Cs6w3G' },
+ { name: 'shop_product_count', selector: '#sll2-pdp-product-shop .NGzCXN > :nth-child(4) .Cs6w3G' },
+ {
+ name: 'shop_product_list_url',
+ selector: '#sll2-pdp-product-shop .NGzCXN a.aArpoe',
+ type: 'attribute',
+ attribute: 'href',
+ transform: 'absolute_url',
+ },
+ { name: 'shop_response_speed', selector: '#sll2-pdp-product-shop .NGzCXN > :nth-child(5) .Cs6w3G' },
+ { name: 'shop_follower_count', selector: '#sll2-pdp-product-shop .NGzCXN > :nth-child(6) .Cs6w3G' },
+];
+
+const PRODUCT_COLUMNS = [
+ 'product_url',
+ ...PRODUCT_FIELDS.map((field) => field.name),
+];
+
+const SHOPEE_WORKSPACE = 'site:shopee';
+
+type BindCurrentTabFn = (
+ workspace: string,
+ opts?: { matchDomain?: string; matchPathPrefix?: string; matchUrl?: string },
+) => Promise;
+
+function mergeProductDetails(
+ current: Record,
+ incoming: Record,
+): Record {
+ const merged = { ...current };
+ for (const [key, value] of Object.entries(incoming)) {
+ const nextValue = String(value ?? '').trim();
+ const currentValue = String(merged[key] ?? '').trim();
+ if (!currentValue && nextValue) {
+ merged[key] = value;
+ }
+ }
+ return merged;
+}
+
+function hasMeaningfulProductData(row: Record): boolean {
+ return PRODUCT_FIELDS.some((field) => String(row[field.name] ?? '').trim() !== '');
+}
+
+async function bindShopeeProductTab(
+ productUrl: string,
+ bindFn: BindCurrentTabFn = bindCurrentTab,
+): Promise {
+ try {
+ await bindFn(SHOPEE_WORKSPACE, { matchUrl: productUrl });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function ensureShopeeProductPage(
+ page: IPage,
+ productUrl: string,
+ bindFn: BindCurrentTabFn = bindCurrentTab,
+): Promise {
+ const reusedExistingTab = await bindShopeeProductTab(productUrl, bindFn);
+ await clearLocalStorageForUrlHost(page, productUrl);
+ await page.goto(productUrl, { waitUntil: 'load' });
+ return reusedExistingTab;
+}
+
+async function extractProductDetails(page: IPage, productUrl: string): Promise> {
+ const baseOrigin = new URL(productUrl).origin;
+ const script = `
+ (() => {
+ const fields = ${JSON.stringify(PRODUCT_FIELDS)};
+ const baseOrigin = ${JSON.stringify(baseOrigin)};
+ const normalizeText = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim();
+ const toScalar = (value) => {
+ if (value === null || value === undefined) return '';
+ return typeof value === 'string' ? value.trim() : String(value);
+ };
+ const applyTransform = (value, field) => {
+ if (field.transform === 'absolute_url') {
+ const text = toScalar(value);
+ return text.startsWith('/') ? baseOrigin + text : text;
+ }
+ if (field.transform === 'image_src') {
+ return toScalar(value);
+ }
+ if (field.transform === 'selected_class') {
+ return /selection-box-selected/.test(toScalar(value)) ? 'true' : 'false';
+ }
+ return value;
+ };
+ const firstUrlFromSrcset = ${firstUrlFromSrcset.toString()};
+ const pickImageUrlFromAttributes = ${pickImageUrlFromAttributes.toString()};
+ const extractImageUrl = (target) => {
+ const candidates = [target];
+ if (typeof target?.querySelector === 'function') {
+ candidates.push(
+ target.querySelector('img'),
+ target.querySelector('picture img'),
+ target.querySelector('source'),
+ target.querySelector('picture source'),
+ );
+ }
+
+ for (const node of candidates) {
+ if (!(node instanceof Element)) continue;
+ const attrs = {
+ src: node.getAttribute('src') || '',
+ 'data-src': node.getAttribute('data-src') || '',
+ 'data-lazy-src': node.getAttribute('data-lazy-src') || '',
+ 'data-original': node.getAttribute('data-original') || '',
+ 'data-img-src': node.getAttribute('data-img-src') || '',
+ srcset: node.getAttribute('srcset') || '',
+ 'data-srcset': node.getAttribute('data-srcset') || '',
+ 'data-lazy-srcset': node.getAttribute('data-lazy-srcset') || '',
+ };
+ const value = pickImageUrlFromAttributes(attrs);
+ if (value) return value;
+ }
+
+ return '';
+ };
+ const isMeaningfulValue = (value) => {
+ if (Array.isArray(value)) return value.some(isMeaningfulValue);
+ if (value && typeof value === 'object') {
+ return Object.values(value).some(isMeaningfulValue);
+ }
+ return toScalar(value) !== '';
+ };
+ const extractFieldValue = (scope, field) => {
+ const selector = typeof field.selector === 'string' ? field.selector.trim() : '';
+
+ if (field.type === 'list') {
+ if (!selector) return '';
+ const itemFields = Array.isArray(field.fields) ? field.fields : [];
+ const items = Array.from(scope.querySelectorAll(selector))
+ .map((node) => {
+ const item = {};
+ for (const childField of itemFields) {
+ item[childField.name] = extractFieldValue(node, childField);
+ }
+ return item;
+ })
+ .filter(isMeaningfulValue);
+
+ if (!items.length) return '';
+ if (itemFields.length === 1 && itemFields[0]?.name === field.name) {
+ return JSON.stringify(items.map((item) => item[field.name] ?? ''));
+ }
+ return JSON.stringify(items);
+ }
+
+ const target = selector ? scope.querySelector(selector) : scope;
+ if (!target) return '';
+
+ if (field.type === 'attribute') {
+ if (field.transform === 'image_src') {
+ return extractImageUrl(target);
+ }
+ const attrName = typeof field.attribute === 'string' && field.attribute.trim()
+ ? field.attribute.trim()
+ : target instanceof HTMLAnchorElement
+ ? 'href'
+ : 'src';
+ return toScalar(applyTransform(target.getAttribute(attrName) || '', field));
+ }
+
+ return normalizeText(applyTransform(target.textContent || '', field));
+ };
+ const row = {};
+
+ for (const field of fields) {
+ row[field.name] = extractFieldValue(document, field);
+ }
+
+ return row;
+ })()
+ `;
+
+
+ let merged: Record = { product_url: productUrl };
+ let lastSnapshot = '';
+
+ for (let round = 0; round < 5; round += 1) {
+ if (round === 0) {
+ await simulateHumanBehavior(page, {
+ selector: 'h1.vR6K3w > span, .shopdoraPirceList span',
+ scrollRangePx: [80, 220],
+ preWaitRangeMs: [350, 900],
+ postWaitRangeMs: [300, 800],
+ });
+ }
+
+ const batch = await page.evaluate(script);
+ const nextRow = typeof batch === 'object' && batch ? batch as Record : {};
+ merged = mergeProductDetails(merged, nextRow);
+
+ const snapshot = JSON.stringify(merged);
+ if (hasMeaningfulProductData(merged) && snapshot === lastSnapshot) {
+ return merged;
+ }
+ lastSnapshot = snapshot;
+
+ if (round < 4) {
+ await simulateHumanBehavior(page, {
+ selector: round < 2 ? '.j7HL5Q button, .detail-info .item-main' : '#sll2-pdp-product-shop',
+ scrollRangePx: [900, 1400],
+ preWaitRangeMs: [220, 700],
+ postWaitRangeMs: [450, 1200],
+ });
+ }
+ }
+
+ return merged;
+}
+
+cli({
+ site: 'shopee',
+ name: 'product',
+ description: 'Get Shopee product details from a product URL',
+ domain: 'shopee.sg',
+ strategy: Strategy.COOKIE,
+ navigateBefore: false,
+ args: [
+ {
+ name: 'url',
+ positional: true,
+ required: true,
+ help: 'Shopee product URL, e.g. https://shopee.sg/...-i.123.456',
+ },
+ ],
+ columns: PRODUCT_COLUMNS,
+ func: async (page, args) => {
+ if (!page) {
+ throw new CommandExecutionError(
+ 'Browser session required for shopee product',
+ 'Run the command with the browser bridge connected',
+ );
+ }
+
+ const productUrl = args.url;
+ await ensureShopeeProductPage(page, productUrl);
+ const row = await extractProductDetails(page, productUrl);
+
+ if (!hasMeaningfulProductData(row)) {
+ throw new EmptyResultError(
+ 'shopee product',
+ 'The product page did not expose any data. Check that the URL is reachable and the browser is logged into Shopee if needed.',
+ );
+ }
+
+ return [row];
+ },
+});
+
+export const __test__ = {
+ PRODUCT_COLUMNS,
+ PRODUCT_FIELDS,
+ mergeProductDetails,
+ hasMeaningfulProductData,
+ firstUrlFromSrcset,
+ pickImageUrlFromAttributes,
+ bindShopeeProductTab,
+ ensureShopeeProductPage,
+};
diff --git a/clis/shopee/shared.test.ts b/clis/shopee/shared.test.ts
new file mode 100644
index 000000000..8505cc5e3
--- /dev/null
+++ b/clis/shopee/shared.test.ts
@@ -0,0 +1,75 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import type { IPage } from '@jackwener/opencli/types';
+import { __test__ } from './shared.js';
+
+describe('shopee shared humanization helpers', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('builds a pointer simulation script for the target selector', () => {
+ const script = __test__.buildHumanPointerScript('.target-button');
+ expect(script).toContain('.target-button');
+ expect(script).toContain('mousemove');
+ expect(script).toContain('scrollIntoView');
+ });
+
+ it('builds a localStorage clearing script scoped to the target host', () => {
+ const script = __test__.buildClearLocalStorageScript('shopee.sg');
+ expect(script).toContain('shopee.sg');
+ expect(script).toContain('localStorage.clear()');
+ expect(script).toContain('host_mismatch');
+ });
+
+ it('waits for a randomized duration within the provided range', async () => {
+ vi.spyOn(Math, 'random').mockReturnValue(0.5);
+ const wait = vi.fn>().mockResolvedValue(undefined);
+ const page = { wait } as unknown as IPage;
+
+ const seconds = await __test__.waitRandomDuration(page, [200, 600]);
+
+ expect(seconds).toBe(0.8);
+ expect(wait).toHaveBeenCalledWith(0.8);
+ });
+
+ it('simulates lightweight human behavior around a selector', async () => {
+ vi.spyOn(Math, 'random')
+ .mockReturnValueOnce(0.5)
+ .mockReturnValueOnce(0.4)
+ .mockReturnValueOnce(0.2)
+ .mockReturnValueOnce(0.3)
+ .mockReturnValueOnce(0.5);
+
+ const page = {
+ wait: vi.fn>().mockResolvedValue(undefined),
+ scroll: vi.fn>().mockResolvedValue(undefined),
+ evaluate: vi.fn>().mockResolvedValue({ ok: true }),
+ } as unknown as IPage;
+
+ await __test__.simulateHumanBehavior(page, {
+ selector: '.target-button',
+ preWaitRangeMs: [200, 600],
+ postWaitRangeMs: [100, 300],
+ scrollRangePx: [100, 300],
+ });
+
+ expect(page.wait).toHaveBeenCalledTimes(2);
+ expect(page.wait).toHaveBeenNthCalledWith(1, 0.8);
+ expect(page.wait).toHaveBeenNthCalledWith(2, 0.4);
+ expect(page.scroll).toHaveBeenCalledWith('down', 180);
+ expect(page.scroll).toHaveBeenCalledWith('up', 58);
+ expect(page.evaluate).toHaveBeenCalledWith(expect.stringContaining('.target-button'));
+ });
+
+ it('navigates to the target origin before clearing localStorage for that host', async () => {
+ const goto = vi.fn>().mockResolvedValue(undefined);
+ const evaluate = vi.fn>().mockResolvedValue({ ok: true, host: 'shopee.sg' });
+ const page = { goto, evaluate } as unknown as IPage;
+
+ await __test__.clearLocalStorageForUrlHost(page, 'https://shopee.sg/product-i.1.2');
+
+ expect(goto).toHaveBeenCalledWith('https://shopee.sg', { waitUntil: 'load' });
+ expect(evaluate).toHaveBeenCalledWith(expect.stringContaining('localStorage.clear()'));
+ expect(evaluate).toHaveBeenCalledWith(expect.stringContaining('shopee.sg'));
+ });
+});
diff --git a/clis/shopee/shared.ts b/clis/shopee/shared.ts
new file mode 100644
index 000000000..db7ff1bfd
--- /dev/null
+++ b/clis/shopee/shared.ts
@@ -0,0 +1,169 @@
+import { CommandExecutionError } from '@jackwener/opencli/errors';
+import type { IPage } from '@jackwener/opencli/types';
+
+type HumanBehaviorOptions = {
+ selector?: string;
+ preWaitRangeMs?: readonly [number, number];
+ postWaitRangeMs?: readonly [number, number];
+ scrollRangePx?: readonly [number, number];
+ allowReverseScroll?: boolean;
+};
+
+const RANDOM_DELAY_MULTIPLIER = 2;
+
+function normalizeRange(range: readonly [number, number]): [number, number] {
+ const [rawMin, rawMax] = range;
+ const min = Number.isFinite(rawMin) ? rawMin : 0;
+ const max = Number.isFinite(rawMax) ? rawMax : min;
+ return min <= max ? [min, max] : [max, min];
+}
+
+function randomInRange(range: readonly [number, number]): number {
+ const [min, max] = normalizeRange(range);
+ if (min === max) return min;
+ return min + Math.random() * (max - min);
+}
+
+function millisecondsToSeconds(value: number): number {
+ return Math.max(0, Number((value / 1000).toFixed(3)));
+}
+
+export async function waitRandomDuration(
+ page: IPage,
+ range: readonly [number, number],
+): Promise {
+ const seconds = millisecondsToSeconds(randomInRange(range) * RANDOM_DELAY_MULTIPLIER);
+ await page.wait(seconds);
+ return seconds;
+}
+
+export function buildClearLocalStorageScript(host: string): string {
+ return `
+ (() => {
+ if (window.location.host !== ${JSON.stringify(host)}) {
+ return {
+ ok: false,
+ reason: 'host_mismatch',
+ expectedHost: ${JSON.stringify(host)},
+ actualHost: window.location.host,
+ };
+ }
+
+ try {
+ window.localStorage.clear();
+ return { ok: true, host: window.location.host };
+ } catch (error) {
+ return {
+ ok: false,
+ reason: 'clear_failed',
+ message: error instanceof Error ? error.message : String(error ?? ''),
+ };
+ }
+ })()
+ `;
+}
+
+export function buildHumanPointerScript(selector: string): string {
+ return `
+ (() => {
+ let target = null;
+ try {
+ target = document.querySelector(${JSON.stringify(selector)});
+ } catch {
+ return { ok: false, reason: 'invalid_selector' };
+ }
+
+ if (!(target instanceof HTMLElement)) {
+ return { ok: false, reason: 'not_found' };
+ }
+
+ target.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' });
+ const rect = target.getBoundingClientRect();
+ const relativeX = 0.25 + Math.random() * 0.5;
+ const relativeY = 0.25 + Math.random() * 0.5;
+ const clientX = Math.round(rect.left + Math.max(1, rect.width * relativeX));
+ const clientY = Math.round(rect.top + Math.max(1, rect.height * relativeY));
+
+ for (const type of ['mousemove', 'mouseenter', 'mouseover']) {
+ try {
+ target.dispatchEvent(new MouseEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ clientX,
+ clientY,
+ view: window,
+ }));
+ } catch {}
+ }
+
+ try {
+ target.focus({ preventScroll: true });
+ } catch {
+ try {
+ target.focus();
+ } catch {}
+ }
+
+ return { ok: true, tag: target.tagName.toLowerCase() };
+ })()
+ `;
+}
+
+async function safeScroll(page: IPage, direction: 'up' | 'down', range: readonly [number, number]): Promise {
+ try {
+ await page.scroll(direction, Math.round(randomInRange(range)));
+ } catch {
+ // Best-effort humanization should not block the primary workflow.
+ }
+}
+
+export async function simulateHumanBehavior(
+ page: IPage,
+ {
+ selector,
+ preWaitRangeMs = [250, 850],
+ postWaitRangeMs = [180, 650],
+ scrollRangePx = [120, 420],
+ allowReverseScroll = true,
+ }: HumanBehaviorOptions = {},
+): Promise {
+ await waitRandomDuration(page, preWaitRangeMs);
+ await safeScroll(page, 'down', scrollRangePx);
+
+ if (selector) {
+ try {
+ await page.evaluate(buildHumanPointerScript(selector));
+ } catch {
+ // Keep the data collection / export flow running even if the selector is absent.
+ }
+ }
+
+ if (allowReverseScroll && Math.random() < 0.35) {
+ await safeScroll(page, 'up', [40, Math.max(80, scrollRangePx[0])]);
+ }
+
+ await waitRandomDuration(page, postWaitRangeMs);
+}
+
+export async function clearLocalStorageForUrlHost(page: IPage, targetUrl: string): Promise {
+ const target = new URL(targetUrl);
+ await page.goto(target.origin, { waitUntil: 'load' });
+ const result = await page.evaluate(buildClearLocalStorageScript(target.host));
+ if (!result || typeof result !== 'object' || !(result as { ok?: boolean }).ok) {
+ throw new CommandExecutionError(
+ `Could not clear localStorage for ${target.host}`,
+ JSON.stringify(result ?? {}),
+ );
+ }
+}
+
+export const __test__ = {
+ RANDOM_DELAY_MULTIPLIER,
+ buildClearLocalStorageScript,
+ buildHumanPointerScript,
+ clearLocalStorageForUrlHost,
+ randomInRange,
+ waitRandomDuration,
+ simulateHumanBehavior,
+};
diff --git a/extension/src/background.ts b/extension/src/background.ts
index da132a9d4..001dd8e32 100644
--- a/extension/src/background.ts
+++ b/extension/src/background.ts
@@ -66,7 +66,11 @@ async function connect(): Promise {
reconnectTimer = null;
}
// Send version so the daemon can report mismatches to the CLI
- ws?.send(JSON.stringify({ type: 'hello', version: chrome.runtime.getManifest().version }));
+ ws?.send(JSON.stringify({
+ type: 'hello',
+ version: chrome.runtime.getManifest().version,
+ extensionId: chrome.runtime.id,
+ }));
};
ws.onmessage = async (event) => {
diff --git a/package.json b/package.json
index b47591e7d..1500ef7a5 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
"./logger": "./dist/src/logger.js",
"./launcher": "./dist/src/launcher.js",
"./browser/cdp": "./dist/src/browser/cdp.js",
+ "./browser/daemon-client": "./dist/src/browser/daemon-client.js",
"./browser/page": "./dist/src/browser/page.js",
"./browser/utils": "./dist/src/browser/utils.js",
"./download": "./dist/src/download/index.js",
diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts
index 451256837..2cdf4e303 100644
--- a/src/browser/bridge.ts
+++ b/src/browser/bridge.ts
@@ -12,6 +12,7 @@ import { Page } from './page.js';
import { getDaemonHealth } from './daemon-client.js';
import { DEFAULT_DAEMON_PORT } from '../constants.js';
import { BrowserConnectError } from '../errors.js';
+import { MAYBE_BROWSER_SCRAPER_ID } from './extension-detect.js';
const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
@@ -78,6 +79,7 @@ export class BrowserBridge implements IBrowserFactory {
'Install the Browser Bridge:\n' +
' 1. Download: https://github.com/jackwener/opencli/releases\n' +
' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
+ ` 3. Confirm the extension ID is ${MAYBE_BROWSER_SCRAPER_ID}\n` +
' Then run: opencli doctor',
'extension-not-connected',
);
@@ -116,6 +118,7 @@ export class BrowserBridge implements IBrowserFactory {
'Install the Browser Bridge:\n' +
' 1. Download: https://github.com/jackwener/opencli/releases\n' +
' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
+ ` 3. Confirm the extension ID is ${MAYBE_BROWSER_SCRAPER_ID}\n` +
' Then run: opencli doctor',
'extension-not-connected',
);
diff --git a/src/browser/daemon-client.test.ts b/src/browser/daemon-client.test.ts
index 7aadfe393..7a211a3cf 100644
--- a/src/browser/daemon-client.test.ts
+++ b/src/browser/daemon-client.test.ts
@@ -94,6 +94,7 @@ describe('daemon-client', () => {
uptime: 10,
extensionConnected: true,
extensionVersion: '1.2.3',
+ extensionExpected: true,
pending: 0,
lastCliRequestTime: Date.now(),
memoryMB: 32,
@@ -106,4 +107,28 @@ describe('daemon-client', () => {
await expect(getDaemonHealth()).resolves.toEqual({ state: 'ready', status });
});
+
+ it('treats a connected but mismatched extension id as no-extension', async () => {
+ const status = {
+ ok: true,
+ pid: 123,
+ uptime: 10,
+ extensionConnected: true,
+ extensionVersion: '1.2.3',
+ extensionId: 'wrongwrongwrongwrongwrongwrongwr',
+ expectedExtensionId: 'gjfgacldoekdalepfgdonkjfngmliogc',
+ extensionExpected: false,
+ lastRejectedExtensionId: 'wrongwrongwrongwrongwrongwrongwr',
+ pending: 0,
+ lastCliRequestTime: Date.now(),
+ memoryMB: 32,
+ port: 19825,
+ };
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve(status),
+ } as Response);
+
+ await expect(getDaemonHealth()).resolves.toEqual({ state: 'no-extension', status });
+ });
});
diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts
index b01b94b5a..5d5dffa62 100644
--- a/src/browser/daemon-client.ts
+++ b/src/browser/daemon-client.ts
@@ -21,7 +21,7 @@ function generateId(): string {
export interface DaemonCommand {
id: string;
- action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind-current' | 'network-capture-start' | 'network-capture-read' | 'cdp';
+ action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind-current' | 'network-capture-start' | 'network-capture-read' | 'cdp' | 'download-wait';
tabId?: number;
code?: string;
workspace?: string;
@@ -31,6 +31,7 @@ export interface DaemonCommand {
domain?: string;
matchDomain?: string;
matchPathPrefix?: string;
+ matchUrl?: string;
format?: 'png' | 'jpeg';
quality?: number;
fullPage?: boolean;
@@ -45,6 +46,10 @@ export interface DaemonCommand {
pattern?: string;
cdpMethod?: string;
cdpParams?: Record;
+ downloadTimeoutMs?: number;
+ downloadStartedAfterMs?: number;
+ downloadUrlPattern?: string;
+ downloadReferrerPattern?: string;
}
export interface DaemonResult {
@@ -60,6 +65,10 @@ export interface DaemonStatus {
uptime: number;
extensionConnected: boolean;
extensionVersion?: string;
+ extensionId?: string | null;
+ expectedExtensionId?: string;
+ extensionExpected?: boolean;
+ lastRejectedExtensionId?: string | null;
pending: number;
lastCliRequestTime: number;
memoryMB: number;
@@ -103,7 +112,7 @@ export type DaemonHealth =
export async function getDaemonHealth(opts?: { timeout?: number }): Promise {
const status = await fetchDaemonStatus(opts);
if (!status) return { state: 'stopped', status: null };
- if (!status.extensionConnected) return { state: 'no-extension', status };
+ if (!status.extensionConnected || status.extensionExpected === false) return { state: 'no-extension', status };
return { state: 'ready', status };
}
@@ -171,6 +180,9 @@ export async function listSessions(): Promise {
return Array.isArray(result) ? result : [];
}
-export async function bindCurrentTab(workspace: string, opts: { matchDomain?: string; matchPathPrefix?: string } = {}): Promise {
+export async function bindCurrentTab(
+ workspace: string,
+ opts: { matchDomain?: string; matchPathPrefix?: string; matchUrl?: string } = {},
+): Promise {
return sendCommand('bind-current', { workspace, ...opts });
}
diff --git a/src/browser/extension-detect.test.ts b/src/browser/extension-detect.test.ts
new file mode 100644
index 000000000..1e1cc7ea2
--- /dev/null
+++ b/src/browser/extension-detect.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ MAYBE_BROWSER_SCRAPER_ID,
+ isExpectedBrowserScraperId,
+ isExpectedExtensionOrigin,
+ parseExtensionIdFromOrigin,
+} from './extension-detect.js';
+
+describe('extension-detect', () => {
+ it('extracts the extension id from a chrome-extension origin', () => {
+ expect(parseExtensionIdFromOrigin(`chrome-extension://${MAYBE_BROWSER_SCRAPER_ID}`)).toBe(
+ MAYBE_BROWSER_SCRAPER_ID,
+ );
+ });
+
+ it('returns null for non-extension origins', () => {
+ expect(parseExtensionIdFromOrigin('https://example.com')).toBeNull();
+ expect(parseExtensionIdFromOrigin(undefined)).toBeNull();
+ });
+
+ it('matches only the expected browser scraper id', () => {
+ expect(isExpectedBrowserScraperId(MAYBE_BROWSER_SCRAPER_ID)).toBe(true);
+ expect(isExpectedBrowserScraperId('abcdefghijklmnopabcdefghijklmnop')).toBe(false);
+ });
+
+ it('matches only the expected extension origin', () => {
+ expect(isExpectedExtensionOrigin(`chrome-extension://${MAYBE_BROWSER_SCRAPER_ID}`)).toBe(true);
+ expect(isExpectedExtensionOrigin('chrome-extension://abcdefghijklmnopabcdefghijklmnop')).toBe(false);
+ });
+});
diff --git a/src/browser/extension-detect.ts b/src/browser/extension-detect.ts
new file mode 100644
index 000000000..da091c368
--- /dev/null
+++ b/src/browser/extension-detect.ts
@@ -0,0 +1,24 @@
+export const MAYBE_BROWSER_SCRAPER_ID = 'gjfgacldoekdalepfgdonkjfngmliogc';
+
+export function parseExtensionIdFromOrigin(origin: string | null | undefined): string | null {
+ const value = typeof origin === 'string' ? origin.trim() : '';
+ if (!value) return null;
+
+ try {
+ const parsed = new URL(value);
+ if (parsed.protocol !== 'chrome-extension:') return null;
+ const extensionId = parsed.hostname.trim();
+ return extensionId || null;
+ } catch {
+ return null;
+ }
+}
+
+export function isExpectedBrowserScraperId(extensionId: string | null | undefined): boolean {
+ return typeof extensionId === 'string'
+ && extensionId.trim() === MAYBE_BROWSER_SCRAPER_ID;
+}
+
+export function isExpectedExtensionOrigin(origin: string | null | undefined): boolean {
+ return isExpectedBrowserScraperId(parseExtensionIdFromOrigin(origin));
+}
diff --git a/src/browser/page.ts b/src/browser/page.ts
index e97f69553..fc62f8d51 100644
--- a/src/browser/page.ts
+++ b/src/browser/page.ts
@@ -10,7 +10,7 @@
* chrome-extension:// tab that can't be debugged.
*/
-import type { BrowserCookie, ScreenshotOptions } from '../types.js';
+import type { BrowserCookie, BrowserDownloadResult, DownloadWaitOptions, ScreenshotOptions } from '../types.js';
import { sendCommand } from './daemon-client.js';
import { wrapForEval } from './utils.js';
import { saveBase64ToFile } from '../utils.js';
@@ -201,6 +201,17 @@ export class Page extends BasePage {
});
}
+ async waitForDownload(options: DownloadWaitOptions = {}): Promise {
+ const result = await sendCommand('download-wait', {
+ downloadTimeoutMs: options.timeoutMs,
+ downloadStartedAfterMs: options.startedAfterMs,
+ downloadUrlPattern: options.urlPattern,
+ downloadReferrerPattern: options.referrerPattern,
+ ...this._cmdOpts(),
+ });
+ return result as BrowserDownloadResult;
+ }
+
/** CDP native click fallback — called when JS el.click() fails */
protected override async tryNativeClick(x: number, y: number): Promise {
try {
diff --git a/src/build-manifest.test.ts b/src/build-manifest.test.ts
index 33bb9691e..f42602ffa 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, resolveManifestBuildPaths, shouldReplaceManifestEntry } from './build-manifest.js';
describe('manifest helper rules', () => {
const tempDirs: string[] = [];
@@ -60,6 +60,29 @@ describe('manifest helper rules', () => {
)).toBe(false);
});
+ it('writes built manifests next to dist/clis when running from dist/', () => {
+ const packageRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-build-paths-'));
+ tempDirs.push(packageRoot);
+ fs.mkdirSync(path.join(packageRoot, 'dist', 'clis'), { recursive: true });
+ fs.writeFileSync(path.join(packageRoot, 'package.json'), '{}');
+
+ expect(resolveManifestBuildPaths(path.join(packageRoot, 'src', 'build-manifest.ts'))).toMatchObject({
+ packageRoot,
+ sourceClisDir: path.join(packageRoot, 'clis'),
+ scanClisDir: path.join(packageRoot, 'clis'),
+ output: path.join(packageRoot, 'cli-manifest.json'),
+ builtExecution: false,
+ });
+
+ expect(resolveManifestBuildPaths(path.join(packageRoot, 'dist', 'src', 'build-manifest.js'))).toMatchObject({
+ packageRoot,
+ sourceClisDir: path.join(packageRoot, 'clis'),
+ scanClisDir: path.join(packageRoot, 'dist', 'clis'),
+ output: path.join(packageRoot, 'dist', 'cli-manifest.json'),
+ builtExecution: true,
+ });
+ });
+
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 bf138970d..1708b50ca 100644
--- a/src/build-manifest.ts
+++ b/src/build-manifest.ts
@@ -5,8 +5,10 @@
* Scans all YAML/TS CLI definitions and pre-compiles them into a single
* manifest.json for instant cold-start registration (no runtime YAML parsing).
*
- * Usage: npx tsx src/build-manifest.ts
- * Output: cli-manifest.json at the package root
+ * Usage:
+ * - `npx tsx src/build-manifest.ts` during development
+ * - `node dist/src/build-manifest.js` during packaging
+ * Output: cli-manifest.json next to the scanned clis/ tree
*/
import * as fs from 'node:fs';
@@ -17,9 +19,35 @@ import { getErrorMessage } from './errors.js';
import { fullName, getRegistry, type CliCommand } from './registry.js';
import { findPackageRoot, getCliManifestPath } from './package-paths.js';
-const PACKAGE_ROOT = findPackageRoot(fileURLToPath(import.meta.url));
-const CLIS_DIR = path.join(PACKAGE_ROOT, 'clis');
-const OUTPUT = getCliManifestPath(CLIS_DIR);
+export interface ManifestBuildPaths {
+ packageRoot: string;
+ sourceClisDir: string;
+ scanClisDir: string;
+ output: string;
+ builtExecution: boolean;
+}
+
+export function resolveManifestBuildPaths(currentFile: string): ManifestBuildPaths {
+ const packageRoot = findPackageRoot(currentFile);
+ const sourceClisDir = path.join(packageRoot, 'clis');
+ const distClisDir = path.join(packageRoot, 'dist', 'clis');
+ const builtExecution = currentFile.replace(/\\/g, '/').includes('/dist/');
+ const scanClisDir = builtExecution && fs.existsSync(distClisDir) ? distClisDir : sourceClisDir;
+
+ return {
+ packageRoot,
+ sourceClisDir,
+ scanClisDir,
+ output: getCliManifestPath(scanClisDir),
+ builtExecution,
+ };
+}
+
+const BUILD_PATHS = resolveManifestBuildPaths(fileURLToPath(import.meta.url));
+const PACKAGE_ROOT = BUILD_PATHS.packageRoot;
+const SOURCE_CLIS_DIR = BUILD_PATHS.sourceClisDir;
+const CLIS_DIR = BUILD_PATHS.scanClisDir;
+const OUTPUT = BUILD_PATHS.output;
export interface ManifestEntry {
site: string;
@@ -46,9 +74,9 @@ export interface ManifestEntry {
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' */
+ /** Relative path from the runtime clis/ dir, e.g. 'bilibili/hot.yaml' or '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 the source clis/ dir (for YAML: 'site/cmd.yaml') */
sourceFile?: string;
/** Pre-navigation control — see CliCommand.navigateBefore */
navigateBefore?: boolean | string;
@@ -73,9 +101,42 @@ function toManifestArgs(args: CliCommand['args']): ManifestEntry['args'] {
}));
}
+function toPosixRelative(fromDir: string, filePath: string): string {
+ return path.relative(fromDir, filePath).split(path.sep).join(path.posix.sep);
+}
+
+function isWithinDir(filePath: string, dir: string): boolean {
+ const relative = path.relative(dir, filePath);
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
+}
+
function toTsModulePath(filePath: string, site: string): string {
- const baseName = path.basename(filePath, path.extname(filePath));
- return `${site}/${baseName}.js`;
+ if (!isWithinDir(filePath, CLIS_DIR)) {
+ const baseName = path.basename(filePath, path.extname(filePath));
+ return `${site}/${baseName}.js`;
+ }
+ const relativePath = toPosixRelative(CLIS_DIR, filePath);
+ return relativePath.replace(/\.[^.]+$/, '.js');
+}
+
+function toSourceFilePath(filePath: string): string {
+ if (!isWithinDir(filePath, CLIS_DIR)) {
+ return path.basename(filePath);
+ }
+ const relativePath = path.relative(CLIS_DIR, filePath);
+ const sourceCandidate = path.join(SOURCE_CLIS_DIR, relativePath);
+
+ if (fs.existsSync(sourceCandidate)) {
+ return toPosixRelative(SOURCE_CLIS_DIR, sourceCandidate);
+ }
+ if (filePath.endsWith('.js')) {
+ const tsCandidate = sourceCandidate.replace(/\.js$/, '.ts');
+ if (fs.existsSync(tsCandidate)) {
+ return toPosixRelative(SOURCE_CLIS_DIR, tsCandidate);
+ }
+ }
+
+ return relativePath.split(path.sep).join(path.posix.sep);
}
function isCliCommandValue(value: unknown, site: string): value is CliCommand {
@@ -137,7 +198,7 @@ function scanYaml(filePath: string, site: string): ManifestEntry | null {
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),
+ sourceFile: toSourceFilePath(filePath),
navigateBefore: cliDef.navigateBefore,
};
} catch (err) {
@@ -184,7 +245,7 @@ export async function loadTsManifestEntries(
return true;
})
.sort((a, b) => a.name.localeCompare(b.name))
- .map(cmd => toManifestEntry(cmd, modulePath, path.relative(CLIS_DIR, filePath)));
+ .map(cmd => toManifestEntry(cmd, modulePath, toSourceFilePath(filePath)));
} catch (err) {
// If parsing fails, log a warning (matching scanYaml behaviour) and skip the entry.
process.stderr.write(`Warning: failed to scan ${filePath}: ${getErrorMessage(err)}\n`);
diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts
index 81cc7bdce..9d9784a0a 100644
--- a/src/commanderAdapter.ts
+++ b/src/commanderAdapter.ts
@@ -31,6 +31,7 @@ import {
CommandExecutionError,
} from './errors.js';
import { getDaemonHealth } from './browser/daemon-client.js';
+import { MAYBE_BROWSER_SCRAPER_ID } from './browser/extension-detect.js';
import { isDiagnosticEnabled } from './diagnostic.js';
export function normalizeArgValue(argType: string | undefined, value: unknown, name: string): unknown {
@@ -187,6 +188,7 @@ function renderBridgeStatus(running: boolean, extensionConnected: boolean): void
console.error(chalk.yellow(' Install the Browser Bridge extension to continue:'));
console.error(chalk.dim(' 1. Download from github.com/jackwener/opencli/releases'));
console.error(chalk.dim(' 2. chrome://extensions → Enable Developer Mode → Load unpacked'));
+ console.error(chalk.dim(` 3. Confirm extension ID: ${MAYBE_BROWSER_SCRAPER_ID}`));
} else {
console.error(chalk.yellow(' Connection failed despite extension being active.'));
console.error(chalk.dim(' Try reloading the extension, or run: opencli doctor'));
diff --git a/src/daemon.ts b/src/daemon.ts
index d278f49d2..f0426eb04 100644
--- a/src/daemon.ts
+++ b/src/daemon.ts
@@ -24,6 +24,12 @@ import { WebSocketServer, WebSocket, type RawData } from 'ws';
import { DEFAULT_DAEMON_PORT, DEFAULT_DAEMON_IDLE_TIMEOUT } from './constants.js';
import { EXIT_CODES } from './errors.js';
import { IdleManager } from './idle-manager.js';
+import {
+ MAYBE_BROWSER_SCRAPER_ID,
+ isExpectedBrowserScraperId,
+ isExpectedExtensionOrigin,
+ parseExtensionIdFromOrigin,
+} from './browser/extension-detect.js';
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
const IDLE_TIMEOUT = Number(process.env.OPENCLI_DAEMON_TIMEOUT ?? DEFAULT_DAEMON_IDLE_TIMEOUT);
@@ -32,6 +38,8 @@ const IDLE_TIMEOUT = Number(process.env.OPENCLI_DAEMON_TIMEOUT ?? DEFAULT_DAEMON
let extensionWs: WebSocket | null = null;
let extensionVersion: string | null = null;
+let extensionId: string | null = null;
+let lastRejectedExtensionId: string | null = null;
const pending = new Map void;
reject: (error: Error) => void;
@@ -132,6 +140,10 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
uptime,
extensionConnected: extensionWs?.readyState === WebSocket.OPEN,
extensionVersion,
+ extensionId,
+ expectedExtensionId: MAYBE_BROWSER_SCRAPER_ID,
+ extensionExpected: isExpectedBrowserScraperId(extensionId),
+ lastRejectedExtensionId,
pending: pending.size,
lastCliRequestTime: idleManager.lastCliRequestTime,
memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
@@ -208,19 +220,22 @@ const wss = new WebSocketServer({
server: httpServer,
path: '/ext',
verifyClient: ({ req }: { req: IncomingMessage }) => {
- // Block browser-originated WebSocket connections. Browsers don't
- // enforce CORS on WebSocket, so a malicious webpage could connect to
- // ws://localhost:19825/ext and impersonate the Extension. Real Chrome
- // Extensions send origin chrome-extension://.
const origin = req.headers['origin'] as string | undefined;
- return !origin || origin.startsWith('chrome-extension://');
+ const incomingId = parseExtensionIdFromOrigin(origin);
+ if (!isExpectedExtensionOrigin(origin)) {
+ lastRejectedExtensionId = incomingId;
+ return false;
+ }
+ lastRejectedExtensionId = null;
+ return true;
},
});
-wss.on('connection', (ws: WebSocket) => {
+wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
console.error('[daemon] Extension connected');
extensionWs = ws;
extensionVersion = null; // cleared until hello message arrives
+ extensionId = parseExtensionIdFromOrigin(req.headers['origin'] as string | undefined);
idleManager.setExtensionConnected(true);
// ── Heartbeat: ping every 15s, close if 2 pongs missed ──
@@ -251,6 +266,14 @@ wss.on('connection', (ws: WebSocket) => {
// Handle hello message from extension (version handshake)
if (msg.type === 'hello') {
extensionVersion = typeof msg.version === 'string' ? msg.version : null;
+ const incomingId = typeof msg.extensionId === 'string' ? msg.extensionId : null;
+ if (!isExpectedBrowserScraperId(incomingId)) {
+ lastRejectedExtensionId = incomingId;
+ ws.close();
+ return;
+ }
+ extensionId = incomingId;
+ lastRejectedExtensionId = null;
return;
}
@@ -280,6 +303,7 @@ wss.on('connection', (ws: WebSocket) => {
if (extensionWs === ws) {
extensionWs = null;
extensionVersion = null;
+ extensionId = null;
idleManager.setExtensionConnected(false);
// Reject all pending requests since the extension is gone
for (const [id, p] of pending) {
@@ -295,6 +319,7 @@ wss.on('connection', (ws: WebSocket) => {
if (extensionWs === ws) {
extensionWs = null;
extensionVersion = null;
+ extensionId = null;
idleManager.setExtensionConnected(false);
// Reject pending requests in case 'close' does not follow this 'error'
for (const [, p] of pending) {
diff --git a/src/discovery.ts b/src/discovery.ts
index 4c5064972..a30a7e10e 100644
--- a/src/discovery.ts
+++ b/src/discovery.ts
@@ -164,7 +164,9 @@ async function loadFromManifest(manifestPath: string, clisDir: string): Promise<
columns: entry.columns,
pipeline: entry.pipeline,
timeoutSeconds: entry.timeout,
- source: entry.sourceFile ? path.resolve(clisDir, entry.sourceFile) : `manifest:${entry.site}/${entry.name}`,
+ source: entry.sourceFile
+ ? resolveManifestSourcePath(clisDir, entry.sourceFile)
+ : `manifest:${entry.site}/${entry.name}`,
deprecated: entry.deprecated,
replacedBy: entry.replacedBy,
navigateBefore: entry.navigateBefore,
@@ -186,7 +188,7 @@ async function loadFromManifest(manifestPath: string, clisDir: string): Promise<
args: entry.args ?? [],
columns: entry.columns,
timeoutSeconds: entry.timeout,
- source: entry.sourceFile ? path.resolve(clisDir, entry.sourceFile) : modulePath,
+ source: entry.sourceFile ? resolveManifestSourcePath(clisDir, entry.sourceFile) : modulePath,
deprecated: entry.deprecated,
replacedBy: entry.replacedBy,
navigateBefore: entry.navigateBefore,
@@ -203,6 +205,20 @@ async function loadFromManifest(manifestPath: string, clisDir: string): Promise<
}
}
+function resolveManifestSourcePath(clisDir: string, sourceFile: string): string {
+ const direct = path.resolve(clisDir, sourceFile);
+ if (fs.existsSync(direct)) return direct;
+
+ const normalizedClisDir = clisDir.replace(/\\/g, '/');
+ if (normalizedClisDir.endsWith('/dist/clis')) {
+ const packageRoot = path.resolve(clisDir, '..', '..');
+ const sourceCandidate = path.resolve(packageRoot, 'clis', sourceFile);
+ if (fs.existsSync(sourceCandidate)) return sourceCandidate;
+ }
+
+ return direct;
+}
+
/**
* Fallback: traditional filesystem scan (used during development with tsx).
*/
diff --git a/src/doctor.ts b/src/doctor.ts
index bff86d368..acb9a0f0f 100644
--- a/src/doctor.ts
+++ b/src/doctor.ts
@@ -8,6 +8,7 @@ import chalk from 'chalk';
import { DEFAULT_DAEMON_PORT } from './constants.js';
import { BrowserBridge } from './browser/index.js';
import { getDaemonHealth, listSessions } from './browser/daemon-client.js';
+import { MAYBE_BROWSER_SCRAPER_ID } from './browser/extension-detect.js';
import { getErrorMessage } from './errors.js';
import { getRuntimeLabel } from './runtime-detect.js';
@@ -101,12 +102,15 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise {
+ const tempBuildRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-manifest-source-'));
+ const distClisDir = path.join(tempBuildRoot, 'dist', 'clis');
+ const distSiteDir = path.join(distClisDir, 'manifest-site');
+ const sourceSiteDir = path.join(tempBuildRoot, 'clis', 'manifest-site');
+ const manifestPath = path.join(tempBuildRoot, 'dist', 'cli-manifest.json');
+ const sourcePath = path.join(sourceSiteDir, 'hello.ts');
+ const modulePath = path.join(distSiteDir, 'hello.js');
+
+ try {
+ await fs.promises.mkdir(distSiteDir, { recursive: true });
+ await fs.promises.mkdir(sourceSiteDir, { recursive: true });
+ await fs.promises.writeFile(sourcePath, 'export const source = true;\n');
+ await fs.promises.writeFile(modulePath, 'export const compiled = true;\n');
+ await fs.promises.writeFile(manifestPath, JSON.stringify([
+ {
+ site: 'manifest-site',
+ name: 'hello',
+ description: 'hello command',
+ strategy: 'public',
+ browser: false,
+ args: [],
+ type: 'ts',
+ modulePath: 'manifest-site/hello.js',
+ sourceFile: 'manifest-site/hello.ts',
+ },
+ ]));
+
+ await discoverClis(distClisDir);
+
+ const cmd = getRegistry().get('manifest-site/hello');
+ expect(cmd).toBeDefined();
+ expect(cmd!.source).toBe(sourcePath);
+ expect((cmd as any)._modulePath).toBe(modulePath);
+ } finally {
+ getRegistry().delete('manifest-site/hello');
+ await fs.promises.rm(tempBuildRoot, { recursive: true, force: true });
+ }
+ });
+
it('loads user CLI modules via package exports symlink', async () => {
const tempOpencliRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-user-clis-'));
const userClisDir = path.join(tempOpencliRoot, 'clis');
diff --git a/src/extension-manifest-regression.test.ts b/src/extension-manifest-regression.test.ts
index 5a0bd68c4..cf74277ee 100644
--- a/src/extension-manifest-regression.test.ts
+++ b/src/extension-manifest-regression.test.ts
@@ -12,6 +12,7 @@ describe('extension manifest regression', () => {
};
expect(manifest.permissions).toContain('cookies');
+ expect(manifest.permissions).toContain('downloads');
expect(manifest.host_permissions).toContain('');
});
});
diff --git a/src/types.ts b/src/types.ts
index 952a7e21a..a2ba6b0b5 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -38,6 +38,28 @@ export interface ScreenshotOptions {
path?: string;
}
+export interface DownloadWaitOptions {
+ timeoutMs?: number;
+ startedAfterMs?: number;
+ urlPattern?: string;
+ referrerPattern?: string;
+}
+
+export interface BrowserDownloadResult {
+ downloadId?: number;
+ filename: string;
+ url?: string;
+ finalUrl?: string;
+ referrer?: string;
+ mime?: string;
+ state?: string;
+ startTime?: string;
+ endTime?: string;
+ fileSize?: number;
+ totalBytes?: number;
+ exists?: boolean;
+}
+
export interface BrowserSessionInfo {
workspace?: string;
connected?: boolean;
@@ -86,6 +108,8 @@ export interface IPage {
getActiveTabId?(): number | undefined;
/** Send a raw CDP command via chrome.debugger passthrough. */
cdp?(method: string, params?: Record): Promise;
+ /** Wait for a browser download triggered by the active page and return the local file path. */
+ waitForDownload?(options?: DownloadWaitOptions): Promise;
/** Click at native coordinates via CDP Input.dispatchMouseEvent. */
nativeClick?(x: number, y: number): Promise;
/** Type text via CDP Input.insertText. */