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/maybeai-image-app/apps.ts b/clis/maybeai-image-app/apps.ts
new file mode 100644
index 000000000..92917fb2b
--- /dev/null
+++ b/clis/maybeai-image-app/apps.ts
@@ -0,0 +1,23 @@
+import { cli, Strategy } from '@jackwener/opencli/registry';
+import { listMaybeAiGeneratedImageApps } from './catalog.js';
+import { inferMaybeAiImageKind } from './resolver.js';
+
+cli({
+ site: 'maybeai-image-app',
+ name: 'apps',
+ description: 'List normalized MaybeAI generated-image apps and their unified CLI fields',
+ strategy: Strategy.PUBLIC,
+ browser: false,
+ args: [],
+ columns: ['group', 'app', 'kind', 'title', 'inputs', 'output'],
+ func: async () => {
+ return listMaybeAiGeneratedImageApps().map((app) => ({
+ group: app.group,
+ app: app.id,
+ kind: inferMaybeAiImageKind(app.id),
+ title: app.title,
+ inputs: app.fields.map((field) => field.key).join(', '),
+ output: app.output.multiple ? 'images[]' : 'image',
+ }));
+ },
+});
diff --git a/clis/maybeai-image-app/catalog.ts b/clis/maybeai-image-app/catalog.ts
new file mode 100644
index 000000000..3f7b3e160
--- /dev/null
+++ b/clis/maybeai-image-app/catalog.ts
@@ -0,0 +1,496 @@
+import { ArgumentError } from '@jackwener/opencli/errors';
+import { validateMaybeAiOption } from './profiles.js';
+
+export type MaybeAiFieldType = 'image' | 'text' | 'number' | 'float' | 'string' | 'string[]';
+
+export interface MaybeAiField {
+ key: string;
+ backendVariable: string;
+ type: MaybeAiFieldType;
+ required?: boolean;
+ multiple?: boolean;
+ description: string;
+}
+
+export interface MaybeAiOutputSchema {
+ type: 'image';
+ multiple: boolean;
+ backendFields: string[];
+}
+
+export interface MaybeAiGeneratedImageApp {
+ id: string;
+ title: string;
+ group: 'model-image' | 'product-image' | 'image-edit';
+ summary: string;
+ sourceRef: string;
+ fields: MaybeAiField[];
+ output: MaybeAiOutputSchema;
+}
+
+const DEFAULT_IMAGE_OUTPUT: MaybeAiOutputSchema = {
+ type: 'image',
+ multiple: true,
+ backendFields: ['url', 'results.url', 'generated_url', 'collage_url', 'output_url'],
+};
+
+function field(
+ key: string,
+ backendVariable: string,
+ type: MaybeAiFieldType,
+ description: string,
+ options: Pick = {},
+): MaybeAiField {
+ return {
+ key,
+ backendVariable,
+ type,
+ description,
+ required: options.required,
+ multiple: options.multiple,
+ };
+}
+
+export const MAYBEAI_GENERATED_IMAGE_APPS: MaybeAiGeneratedImageApp[] = [
+ {
+ id: 'try-on',
+ title: '单件模特穿搭',
+ group: 'model-image',
+ summary: '商品图 + 模特图,生成单件商品上身图。',
+ sourceRef: 'maybeai-shell-app:try-on',
+ fields: [
+ field('products', 'variable:series:product_image_url', 'image', '商品图', { required: true, multiple: true }),
+ field('person', 'variable:scalar:reference_image_url', 'image', '参考模特图'),
+ field('market', 'variable:scalar:target_market', 'string', '目标市场'),
+ field('category', 'variable:scalar:category', 'string', '商品类目'),
+ field('count', 'variable:scalar:image_count', 'number', '生成图片数量'),
+ field('ratio', 'variable:scalar:aspect_ratio', 'string', '宽高比'),
+ field('resolution', 'variable:scalar:resolution', 'string', '分辨率'),
+ field('prompt', 'variable:scalar:user_description', 'text', '额外生成要求'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: 'change-model',
+ title: '换模特',
+ group: 'model-image',
+ summary: '商品图 + 参考模特图,生成不同模特版本。',
+ sourceRef: 'maybeai-shell-app:change-model',
+ fields: [
+ field('products', 'variable:series:product_image_url', 'image', '商品图', { required: true, multiple: true }),
+ field('person', 'variable:scalar:reference_image_url', 'image', '参考模特图'),
+ field('market', 'variable:scalar:target_market', 'string', '目标市场'),
+ field('count', 'variable:scalar:image_count', 'number', '生成图片数量'),
+ field('ratio', 'variable:scalar:aspect_ratio', 'string', '宽高比'),
+ field('resolution', 'variable:scalar:resolution', 'string', '分辨率'),
+ field('prompt', 'variable:scalar:user_description', 'text', '额外生成要求'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: 'mix-match',
+ title: '多件融合模特穿搭',
+ group: 'model-image',
+ summary: '多商品图 + 模特图,融合生成一张模特穿搭图。',
+ sourceRef: 'maybeai-shell-app:mix-match',
+ fields: [
+ field('products', 'variable:series:product_image_url', 'image', '商品图', { required: true, multiple: true }),
+ field('person', 'variable:scalar:reference_image_url', 'image', '参考模特图'),
+ field('market', 'variable:scalar:target_market', 'string', '目标市场'),
+ field('ratio', 'variable:scalar:aspect_ratio', 'string', '宽高比'),
+ field('resolution', 'variable:scalar:resolution', 'string', '分辨率'),
+ field('prompt', 'variable:scalar:user_description', 'text', '额外生成要求'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: 'change-action',
+ title: '换动作',
+ group: 'model-image',
+ summary: '原图 + 动作参考图,裂变生成不同动作图。',
+ sourceRef: 'maybeai-shell-app:change-action',
+ fields: [
+ field('product', 'variable:scalar:product_image_url', 'image', '原图', { required: true }),
+ field('actions', 'variable:series:reference_image_url', 'image', '动作参考图', { multiple: true }),
+ field('ratio', 'variable:scalar:aspect_ratio', 'string', '宽高比'),
+ field('resolution', 'variable:scalar:resolution', 'string', '分辨率'),
+ field('prompt', 'variable:scalar:user_description', 'text', '额外生成要求'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: 'change-product',
+ title: '商品替换',
+ group: 'product-image',
+ summary: '商品图 + 场景参考图,把原场景中的商品替换为目标商品。',
+ sourceRef: 'maybeai-shell-app:change-product',
+ fields: [
+ field('products', 'variable:series:product_image_url', 'image', '商品图', { required: true, multiple: true }),
+ field('scene', 'variable:scalar:reference_image_url', 'image', '场景参考图'),
+ field('ratio', 'variable:scalar:aspect_ratio', 'string', '宽高比'),
+ field('resolution', 'variable:scalar:resolution', 'string', '分辨率'),
+ field('prompt', 'variable:scalar:user_description', 'text', '替换要求'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: 'change-background',
+ title: '换场景',
+ group: 'product-image',
+ summary: '商品图 + 场景参考图,生成新背景场景。',
+ sourceRef: 'maybeai-shell-app:change-background',
+ fields: [
+ field('product', 'variable:scalar:product_image_url', 'image', '商品图', { required: true }),
+ field('scene', 'variable:scalar:reference_image_url', 'image', '场景参考图'),
+ field('ratio', 'variable:scalar:aspect_ratio', 'string', '宽高比'),
+ field('resolution', 'variable:scalar:resolution', 'string', '分辨率'),
+ field('prompt', 'variable:scalar:user_description', 'text', '场景要求'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: 'gen-main',
+ title: '商品主图',
+ group: 'product-image',
+ summary: '商品图 + 模板图,生成电商主图。',
+ sourceRef: 'maybeai-shell-app:gen-main',
+ fields: [
+ field('products', 'variable:series:product_image_url', 'image', '商品图', { required: true, multiple: true }),
+ field('template', 'variable:scalar:reference_image_url', 'image', '主图参考模板'),
+ field('market', 'variable:scalar:target_market', 'string', '目标市场'),
+ field('platform', 'variable:scalar:platform', 'string', '目标平台'),
+ field('category', 'variable:scalar:category', 'string', '商品类目'),
+ field('count', 'variable:scalar:image_count', 'number', '生成图片数量'),
+ field('ratio', 'variable:scalar:aspect_ratio', 'string', '宽高比'),
+ field('resolution', 'variable:scalar:resolution', 'string', '分辨率'),
+ field('prompt', 'variable:scalar:user_description', 'text', '主图要求'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: 'gen-scene',
+ title: '场景图',
+ group: 'product-image',
+ summary: '商品图生成场景化商品图。',
+ sourceRef: 'maybeai-shell-app:gen-scene',
+ fields: [
+ field('products', 'variable:series:product_image_url', 'image', '商品图', { required: true, multiple: true }),
+ field('market', 'variable:scalar:target_market', 'string', '目标市场'),
+ field('platform', 'variable:scalar:platform', 'string', '目标平台'),
+ field('category', 'variable:scalar:category', 'string', '商品类目'),
+ field('count', 'variable:scalar:image_count', 'number', '生成图片数量'),
+ field('ratio', 'variable:scalar:aspect_ratio', 'string', '宽高比'),
+ field('resolution', 'variable:scalar:resolution', 'string', '分辨率'),
+ field('prompt', 'variable:scalar:user_description', 'text', '场景要求'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: 'gen-details',
+ title: '细节特写图',
+ group: 'product-image',
+ summary: '商品图 + 属性图,生成特写细节图。',
+ sourceRef: 'maybeai-shell-app:gen-details',
+ fields: [
+ field('product_and_attrs', 'variable:dataframe:product_image_url', 'image', '商品图与属性图组合', { required: true, multiple: true }),
+ field('market', 'variable:scalar:target_market', 'string', '目标市场'),
+ field('platform', 'variable:scalar:platform', 'string', '目标平台'),
+ field('category', 'variable:scalar:category', 'string', '商品类目'),
+ field('prompt', 'variable:scalar:user_description', 'text', '细节要求'),
+ field('count', 'variable:scalar:image_count', 'number', '生成图片数量'),
+ field('ratio', 'variable:scalar:aspect_ratio', 'string', '宽高比'),
+ field('resolution', 'variable:scalar:resolution', 'string', '分辨率'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: 'details-selling-points',
+ title: '商品卖点图',
+ group: 'product-image',
+ summary: '商品图 + 属性图,生成卖点说明图。',
+ sourceRef: 'maybeai-shell-app:details-selling-points',
+ fields: [
+ field('product_and_attrs', 'variable:dataframe:product_image_url', 'image', '商品图与属性图组合', { required: true, multiple: true }),
+ field('category', 'variable:scalar:category', 'string', '商品类目'),
+ field('count', 'variable:scalar:image_count', 'number', '生成图片数量'),
+ field('ratio', 'variable:scalar:aspect_ratio', 'string', '宽高比'),
+ field('resolution', 'variable:scalar:resolution', 'string', '分辨率'),
+ field('prompt', 'variable:scalar:user_description', 'text', '卖点要求'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: 'add-selling-points',
+ title: '加卖点标注',
+ group: 'product-image',
+ summary: '商品图 + 属性图,给结果图加卖点标注。',
+ sourceRef: 'maybeai-shell-app:add-selling-points',
+ fields: [
+ field('product_and_attrs', 'variable:dataframe:product_image_url', 'image', '商品图与属性图组合', { required: true, multiple: true }),
+ field('prompt', 'variable:scalar:user_description', 'text', '卖点标注要求'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: 'gen-multi-angles',
+ title: '角度图',
+ group: 'product-image',
+ summary: '商品图,按多个角度生成展示图。',
+ sourceRef: 'maybeai-shell-app:gen-multi-angles',
+ fields: [
+ field('products', 'variable:series:product_image_url', 'image', '商品图', { required: true, multiple: true }),
+ field('person', 'variable:scalar:reference_image_url', 'image', '参考模特图'),
+ field('market', 'variable:scalar:target_market', 'string', '目标市场'),
+ field('platform', 'variable:scalar:platform', 'string', '目标平台'),
+ field('category', 'variable:scalar:category', 'string', '商品类目'),
+ field('angles', 'variable:series:angle', 'string[]', '角度列表', { required: true, multiple: true }),
+ field('prompt', 'variable:scalar:user_description', 'text', '展示要求'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: 'gen-size-compare',
+ title: '尺码对比图',
+ group: 'product-image',
+ summary: '商品图 + 尺码图,生成尺码对比展示图。',
+ sourceRef: 'maybeai-shell-app:gen-size-compare',
+ fields: [
+ field('product_and_size_chart', 'variable:dataframe:product_image_url', 'image', '商品图与尺码图组合', { required: true, multiple: true }),
+ field('prompt', 'variable:scalar:user_description', 'text', '尺码对比要求'),
+ field('ratio', 'variable:scalar:aspect_ratio', 'string', '宽高比'),
+ field('resolution', 'variable:scalar:resolution', 'string', '分辨率'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: 'creative-image-generation',
+ title: '创意素材',
+ group: 'product-image',
+ summary: '风格参考图 + 文案,生成创意素材图。',
+ sourceRef: 'maybeai-shell-app:creative-image-generation',
+ fields: [
+ field('style', 'variable:scalar:reference_style', 'image', '风格参考图'),
+ field('prompt', 'variable:scalar:user_description', 'text', '创意生成要求'),
+ field('count', 'variable:scalar:number_of_images', 'number', '生成图片数量'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: 'pattern-extraction',
+ title: '图案提取',
+ group: 'image-edit',
+ summary: '提取商品图中的图案或印花。',
+ sourceRef: 'maybeai-shell-app:pattern-extraction',
+ fields: [
+ field('product', 'variable:scalar:product_image_url', 'image', '商品图', { required: true }),
+ field('prompt', 'variable:scalar:user_description', 'text', '图案提取要求'),
+ field('background', 'variable:scalar:background', 'string', '输出背景'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: 'pattern-fission',
+ title: '图案裂变',
+ group: 'image-edit',
+ summary: '基于已有图案生成多种新图案。',
+ sourceRef: 'maybeai-shell-app:pattern-fission',
+ fields: [
+ field('product', 'variable:scalar:product_image_url', 'image', '图案原图', { required: true }),
+ field('similarity', 'variable:scalar:similarity', 'float', '相似度'),
+ field('prompt', 'variable:scalar:user_description', 'text', '裂变要求'),
+ field('count', 'variable:scalar:number_of_images', 'number', '生成图片数量'),
+ field('background', 'variable:scalar:background', 'string', '输出背景'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: 'scene-fission',
+ title: '场景裂变',
+ group: 'image-edit',
+ summary: '基于单张商品场景图生成多个新场景。',
+ sourceRef: 'maybeai-shell-app:scene-fission',
+ fields: [
+ field('product', 'variable:scalar:product_image_url', 'image', '商品图', { required: true }),
+ field('similarity', 'variable:scalar:similarity', 'float', '相似度'),
+ field('prompt', 'variable:scalar:user_description', 'text', '场景裂变要求'),
+ field('count', 'variable:scalar:number_of_images', 'number', '生成图片数量'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: '3d-from-2d',
+ title: '服装 3D 图',
+ group: 'image-edit',
+ summary: '单张服装图转 3D 效果图。',
+ sourceRef: 'maybeai-shell-app:3d-from-2d',
+ fields: [
+ field('product', 'variable:scalar:product_image_url', 'image', '商品图', { required: true }),
+ field('prompt', 'variable:scalar:user_description', 'text', '3D 生成要求'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: 'product-modification',
+ title: '款式裂变',
+ group: 'image-edit',
+ summary: '商品图生成不同款式变体。',
+ sourceRef: 'maybeai-shell-app:product-modification',
+ fields: [
+ field('product', 'variable:scalar:product_image_url', 'image', '商品图', { required: true }),
+ field('similarity', 'variable:scalar:similarity', 'float', '相似度'),
+ field('prompt', 'variable:scalar:user_description', 'text', '款式修改要求'),
+ field('count', 'variable:scalar:number_of_images', 'number', '生成图片数量'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: 'change-color',
+ title: '换颜色',
+ group: 'image-edit',
+ summary: '商品图 + 颜色参考图,生成新颜色版本。',
+ sourceRef: 'maybeai-shell-app:change-color',
+ fields: [
+ field('product', 'variable:scalar:product_image_url', 'image', '商品图', { required: true }),
+ field('color_ref', 'variable:scalar:reference_image_url', 'image', '颜色参考图'),
+ field('prompt', 'variable:scalar:user_description', 'text', '换色要求'),
+ field('ratio', 'variable:scalar:aspect_ratio', 'string', '宽高比'),
+ field('resolution', 'variable:scalar:resolution', 'string', '分辨率'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: 'remove-background',
+ title: '白底/透明图',
+ group: 'image-edit',
+ summary: '商品图去背景,生成白底或透明图。',
+ sourceRef: 'maybeai-shell-app:remove-background',
+ fields: [
+ field('products', 'variable:series:product_image_url', 'image', '商品图', { required: true, multiple: true }),
+ field('prompt', 'variable:scalar:user_description', 'text', '背景处理要求'),
+ field('background', 'variable:scalar:background', 'string', '输出背景'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: 'remove-watermark',
+ title: '去水印',
+ group: 'image-edit',
+ summary: '商品图去水印。',
+ sourceRef: 'maybeai-shell-app:remove-watermark',
+ fields: [
+ field('products', 'variable:series:product_image_url', 'image', '商品图', { required: true, multiple: true }),
+ field('ratio', 'variable:scalar:aspect_ratio', 'string', '宽高比'),
+ field('resolution', 'variable:scalar:resolution', 'string', '分辨率'),
+ field('prompt', 'variable:scalar:user_description', 'text', '去水印要求'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+ {
+ id: 'remove-face',
+ title: '模糊人脸/去人脸',
+ group: 'image-edit',
+ summary: '对商品图中的人脸进行模糊或去除。',
+ sourceRef: 'maybeai-shell-app:remove-face',
+ fields: [
+ field('products', 'variable:series:product_image_url', 'image', '商品图', { required: true, multiple: true }),
+ field('prompt', 'variable:scalar:user_description', 'text', '人脸处理要求'),
+ field('engine', 'variable:scalar:llm_model', 'string', '底层图像模型'),
+ ],
+ output: DEFAULT_IMAGE_OUTPUT,
+ },
+];
+
+export function listMaybeAiGeneratedImageApps(): MaybeAiGeneratedImageApp[] {
+ return MAYBEAI_GENERATED_IMAGE_APPS;
+}
+
+export function getMaybeAiGeneratedImageApp(appId: string): MaybeAiGeneratedImageApp {
+ const app = MAYBEAI_GENERATED_IMAGE_APPS.find((item) => item.id === appId);
+ if (!app) {
+ const supported = MAYBEAI_GENERATED_IMAGE_APPS.map((item) => item.id).join(', ');
+ throw new ArgumentError(`Unknown maybeai-image-app app: ${appId}`, `Supported apps: ${supported}`);
+ }
+ return app;
+}
+
+export function toWorkflowVariables(app: MaybeAiGeneratedImageApp, input: Record): Array<{ name: string; default_value: unknown }> {
+ const variables: Array<{ name: string; default_value: unknown }> = [];
+ const remaining = new Set(Object.keys(input));
+
+ for (const fieldDef of app.fields) {
+ const value = input[fieldDef.key];
+ remaining.delete(fieldDef.key);
+
+ if (value === undefined || value === null || value === '') {
+ if (fieldDef.required) {
+ throw new ArgumentError(`Missing required field: ${fieldDef.key}`, `Check schema with: opencli maybeai-image-app schema ${app.id}`);
+ }
+ continue;
+ }
+
+ validateCanonicalFieldValue(fieldDef.key, value);
+
+ variables.push({
+ name: fieldDef.backendVariable,
+ default_value: value,
+ });
+ }
+
+ if (remaining.size > 0) {
+ throw new ArgumentError(`Unknown input fields: ${Array.from(remaining).join(', ')}`, `Check schema with: opencli maybeai-image-app schema ${app.id}`);
+ }
+
+ return variables;
+}
+
+function validateCanonicalFieldValue(fieldKey: string, value: unknown): void {
+ switch (fieldKey) {
+ case 'platform':
+ validateMaybeAiOption('platform', value, fieldKey);
+ return;
+ case 'market':
+ case 'country':
+ case 'region':
+ validateMaybeAiOption('country', value, fieldKey);
+ return;
+ case 'category':
+ validateMaybeAiOption('category', value, fieldKey);
+ return;
+ case 'angles':
+ validateMaybeAiOption('angle', value, fieldKey);
+ return;
+ case 'ratio':
+ validateMaybeAiOption('ratio', value, fieldKey);
+ return;
+ case 'resolution':
+ validateMaybeAiOption('resolution', value, fieldKey);
+ return;
+ case 'engine':
+ validateMaybeAiOption('model', value, fieldKey);
+ return;
+ default:
+ return;
+ }
+}
diff --git a/clis/maybeai-image-app/generate.ts b/clis/maybeai-image-app/generate.ts
new file mode 100644
index 000000000..e190d2b0c
--- /dev/null
+++ b/clis/maybeai-image-app/generate.ts
@@ -0,0 +1,139 @@
+import { cli, Strategy } from '@jackwener/opencli/registry';
+import { CliError } from '@jackwener/opencli/errors';
+import { getMaybeAiGeneratedImageApp } from './catalog.js';
+import { readJsonObjectInput, mergeDefinedCliValues } from './input.js';
+import { MAYBEAI_IMAGE_KINDS } from './profiles.js';
+import { resolveMaybeAiGeneratedImageInput } from './resolver.js';
+import { getMaybeAiWorkflowProfile } from './workflow-profiles.js';
+import {
+ MaybeAiWorkflowClient,
+ buildSecondStepVariablesV2,
+ extractGeneratedImages,
+ readMaybeAiWorkflowClientOptions,
+} from './workflow-client.js';
+
+cli({
+ site: 'maybeai-image-app',
+ name: 'generate',
+ description: 'Generate images by running MaybeAI workflows; prompt generation is handled internally',
+ strategy: Strategy.PUBLIC,
+ browser: false,
+ defaultFormat: 'json',
+ args: [
+ { name: 'app', positional: true, required: true, help: 'MaybeAI app id, e.g. gen-main' },
+ { name: 'json', help: 'Inline JSON payload using normalized CLI keys' },
+ { name: 'file', help: 'Path to a JSON payload file' },
+ { name: 'platform', help: 'Target platform, e.g. Amazon, Shopee, XiaoHongShu' },
+ { name: 'image-kind', choices: [...MAYBEAI_IMAGE_KINDS], help: 'Image kind for platform ratio adaptation' },
+ { name: 'market', help: 'Target country/region' },
+ { name: 'category', help: 'Product category' },
+ { name: 'ratio', help: 'Override aspect ratio' },
+ { name: 'resolution', help: 'Override resolution' },
+ { name: 'engine', help: 'Override Shell image model' },
+ { name: 'prompt', help: 'Extra generation requirements' },
+ { name: 'task-id', help: 'Optional workflow task id for tracing' },
+ ],
+ func: async (_page, kwargs) => {
+ const appId = String(kwargs.app);
+ const app = getMaybeAiGeneratedImageApp(appId);
+ const workflow = getMaybeAiWorkflowProfile(appId);
+ const baseInput = readJsonObjectInput(
+ typeof kwargs.file === 'string' ? kwargs.file : undefined,
+ typeof kwargs.json === 'string' ? kwargs.json : undefined,
+ );
+ const input = mergeDefinedCliValues(baseInput, kwargs, [
+ 'platform',
+ 'market',
+ 'category',
+ 'ratio',
+ 'resolution',
+ 'engine',
+ 'prompt',
+ ]);
+
+ if (typeof kwargs['image-kind'] === 'string') {
+ input.imageKind = kwargs['image-kind'];
+ }
+
+ const resolved = resolveMaybeAiGeneratedImageInput(appId, input);
+ const client = new MaybeAiWorkflowClient(readMaybeAiWorkflowClientOptions());
+ const taskId = typeof kwargs['task-id'] === 'string' ? kwargs['task-id'] : undefined;
+
+ const rawResults = workflow.mode === 'direct'
+ ? await client.run({
+ artifactId: workflow.resultArtifactId,
+ variables: resolved.variables,
+ appId,
+ title: app.title,
+ taskId,
+ service: workflow.service,
+ })
+ : await runTwoStepWorkflow(client, {
+ appId,
+ title: app.title,
+ taskId,
+ promptArtifactId: workflow.promptArtifactId,
+ resultArtifactId: workflow.resultArtifactId,
+ variables: resolved.variables,
+ includeLlmModel: app.fields.some((field) => field.backendVariable === 'variable:scalar:llm_model'),
+ service: workflow.service,
+ });
+
+ const images = extractGeneratedImages(rawResults, app.output.backendFields);
+ if (images.length === 0) {
+ throw new CliError('EMPTY_RESULT', 'Workflow completed but no generated image URL was found', JSON.stringify(rawResults).slice(0, 1000));
+ }
+
+ return {
+ app: app.id,
+ title: app.title,
+ mode: workflow.mode,
+ images,
+ resolvedInput: resolved.input,
+ modelProfile: resolved.modelProfile,
+ warnings: resolved.warnings,
+ };
+ },
+});
+
+async function runTwoStepWorkflow(
+ client: MaybeAiWorkflowClient,
+ options: {
+ appId: string;
+ title: string;
+ taskId?: string;
+ promptArtifactId: string;
+ resultArtifactId: string;
+ variables: Array<{ name: string; default_value: unknown }>;
+ includeLlmModel: boolean;
+ service: string;
+ },
+): Promise {
+ const promptTaskId = crypto.randomUUID();
+ const promptConfigs = await client.run({
+ artifactId: options.promptArtifactId,
+ variables: options.variables,
+ appId: options.appId,
+ title: options.title,
+ taskId: promptTaskId,
+ useSystemAuth: true,
+ service: options.service,
+ });
+
+ const secondStepVariables = buildSecondStepVariablesV2(
+ promptConfigs.filter((item): item is Record => !!item && typeof item === 'object' && !Array.isArray(item)),
+ options.variables,
+ options.appId,
+ options.includeLlmModel,
+ );
+
+ return client.run({
+ artifactId: options.resultArtifactId,
+ variables: secondStepVariables,
+ appId: options.appId,
+ title: options.title,
+ taskId: options.taskId,
+ prevTaskId: promptTaskId,
+ service: options.service,
+ });
+}
diff --git a/clis/maybeai-image-app/input.ts b/clis/maybeai-image-app/input.ts
new file mode 100644
index 000000000..6242e3739
--- /dev/null
+++ b/clis/maybeai-image-app/input.ts
@@ -0,0 +1,39 @@
+import * as fs from 'node:fs';
+import { ArgumentError } from '@jackwener/opencli/errors';
+
+export function readJsonObjectInput(filePath: string | undefined, rawJson: string | undefined, options: { required?: boolean } = {}): Record {
+ const required = options.required ?? true;
+
+ if (filePath && rawJson) {
+ throw new ArgumentError('Use either --file or --json, not both');
+ }
+ if (!filePath && !rawJson) {
+ if (!required) return {};
+ throw new ArgumentError('Missing payload input', 'Pass --json \'{"product":"..."}\' or --file payload.json');
+ }
+
+ const source = filePath
+ ? fs.readFileSync(filePath, 'utf8')
+ : rawJson!;
+
+ try {
+ const parsed = JSON.parse(source) as Record;
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new Error('Payload must be a JSON object');
+ }
+ return parsed;
+ } catch (error) {
+ throw new ArgumentError(`Invalid JSON payload: ${error instanceof Error ? error.message : String(error)}`);
+ }
+}
+
+export function mergeDefinedCliValues(input: Record, kwargs: Record, keys: string[]): Record {
+ const merged = { ...input };
+ for (const key of keys) {
+ const value = kwargs[key];
+ if (value !== undefined && value !== null && value !== '') {
+ merged[key] = value;
+ }
+ }
+ return merged;
+}
diff --git a/clis/maybeai-image-app/model-profiles.ts b/clis/maybeai-image-app/model-profiles.ts
new file mode 100644
index 000000000..874725172
--- /dev/null
+++ b/clis/maybeai-image-app/model-profiles.ts
@@ -0,0 +1,178 @@
+import { ArgumentError } from '@jackwener/opencli/errors';
+import {
+ MAYBEAI_DEFAULT_IMAGE_MODEL_PRIORITY,
+ type MaybeAiAspectRatio,
+ type MaybeAiImageModel,
+ type MaybeAiResolution,
+} from './profiles.js';
+
+export interface MaybeAiModelRuleSource {
+ label: string;
+ url?: string;
+ confidence: 'official' | 'inferred';
+}
+
+export interface MaybeAiImageModelProfile {
+ model: MaybeAiImageModel;
+ priority: number | null;
+ defaultRatio: MaybeAiAspectRatio;
+ supportedRatios: MaybeAiAspectRatio[];
+ supportedResolutions?: MaybeAiResolution[];
+ notes: string[];
+ sources: MaybeAiModelRuleSource[];
+}
+
+const STANDARD_IMAGE_RATIOS: MaybeAiAspectRatio[] = [
+ 'auto',
+ '21:9',
+ '16:9',
+ '3:2',
+ '4:3',
+ '5:4',
+ '1:1',
+ '4:5',
+ '3:4',
+ '2:3',
+ '9:16',
+];
+
+const GEMINI_FLASH_RATIOS: MaybeAiAspectRatio[] = [
+ ...STANDARD_IMAGE_RATIOS,
+ '4:1',
+ '1:4',
+ '8:1',
+ '1:8',
+];
+
+export const MAYBEAI_IMAGE_MODEL_PROFILES: Record = {
+ 'google/gemini-3.1-flash-image-preview': {
+ model: 'google/gemini-3.1-flash-image-preview',
+ priority: 1,
+ defaultRatio: '1:1',
+ supportedRatios: GEMINI_FLASH_RATIOS,
+ supportedResolutions: ['1K', '2K', '4K'],
+ notes: ['默认首选模型;官方 Gemini image config 支持标准比例和 4:1/1:4/8:1/1:8 等扩展比例。'],
+ sources: [
+ {
+ label: 'Google Gemini API image generation ImageConfig',
+ url: 'https://ai.google.dev/api/generate-content',
+ confidence: 'official',
+ },
+ ],
+ },
+ 'fal-ai/nano-banana-2/edit': {
+ model: 'fal-ai/nano-banana-2/edit',
+ priority: 2,
+ defaultRatio: '1:1',
+ supportedRatios: GEMINI_FLASH_RATIOS,
+ supportedResolutions: ['1K', '2K', '4K'],
+ notes: ['第二优先级;fal 官方 schema 暴露的 aspect_ratio 与 Gemini Flash 族比例保持一致。'],
+ sources: [
+ {
+ label: 'fal.ai nano-banana-2 edit API schema',
+ url: 'https://fal.ai/models/fal-ai/nano-banana-2/edit/api',
+ confidence: 'official',
+ },
+ ],
+ },
+ 'google/gemini-3-pro-image-preview': {
+ model: 'google/gemini-3-pro-image-preview',
+ priority: 3,
+ defaultRatio: '1:1',
+ supportedRatios: STANDARD_IMAGE_RATIOS,
+ supportedResolutions: ['1K', '2K', '4K'],
+ notes: ['第三优先级;用于 Shell 中部分固定为 Pro 的编辑类 app。'],
+ sources: [
+ {
+ label: 'Google Gemini API image generation ImageConfig',
+ url: 'https://ai.google.dev/api/generate-content',
+ confidence: 'official',
+ },
+ ],
+ },
+ 'fal-ai/nano-banana-pro/edit': {
+ model: 'fal-ai/nano-banana-pro/edit',
+ priority: 4,
+ defaultRatio: '1:1',
+ supportedRatios: STANDARD_IMAGE_RATIOS,
+ supportedResolutions: ['1K', '2K', '4K'],
+ notes: ['第四优先级;fal 官方 schema 支持电商常用标准比例。'],
+ sources: [
+ {
+ label: 'fal.ai nano-banana-pro edit API schema',
+ url: 'https://fal.ai/models/fal-ai/nano-banana-pro/edit/api',
+ confidence: 'official',
+ },
+ ],
+ },
+ 'fal-ai/gpt-image-1.5/edit': {
+ model: 'fal-ai/gpt-image-1.5/edit',
+ priority: null,
+ defaultRatio: '1:1',
+ supportedRatios: STANDARD_IMAGE_RATIOS,
+ supportedResolutions: ['1K', '2K', '4K'],
+ notes: ['Shell 支持模型,但不在 MaybeAI 默认优先级中;比例按 Shell/Fal 常用标准比例保守处理。'],
+ sources: [
+ {
+ label: 'MaybeAI Shell image model option',
+ confidence: 'inferred',
+ },
+ ],
+ },
+ 'fal-ai/qwen-image-edit-2511': {
+ model: 'fal-ai/qwen-image-edit-2511',
+ priority: null,
+ defaultRatio: '1:1',
+ supportedRatios: STANDARD_IMAGE_RATIOS,
+ supportedResolutions: ['1K', '2K', '4K'],
+ notes: ['Shell 支持模型,但不在 MaybeAI 默认优先级中;比例按 Shell/Fal 常用标准比例保守处理。'],
+ sources: [
+ {
+ label: 'MaybeAI Shell image model option',
+ confidence: 'inferred',
+ },
+ ],
+ },
+};
+
+export function getMaybeAiImageModelProfile(model: MaybeAiImageModel): MaybeAiImageModelProfile {
+ return MAYBEAI_IMAGE_MODEL_PROFILES[model];
+}
+
+export function listMaybeAiImageModelProfiles(): MaybeAiImageModelProfile[] {
+ return Object.values(MAYBEAI_IMAGE_MODEL_PROFILES).sort((left, right) => {
+ const leftPriority = left.priority ?? Number.MAX_SAFE_INTEGER;
+ const rightPriority = right.priority ?? Number.MAX_SAFE_INTEGER;
+ if (leftPriority !== rightPriority) return leftPriority - rightPriority;
+ return left.model.localeCompare(right.model);
+ });
+}
+
+export function supportsMaybeAiRatio(model: MaybeAiImageModel, ratio: MaybeAiAspectRatio | undefined): boolean {
+ if (!ratio || ratio === 'auto') return true;
+ return getMaybeAiImageModelProfile(model).supportedRatios.includes(ratio);
+}
+
+export function selectMaybeAiDefaultModelForRatio(ratio: MaybeAiAspectRatio | undefined): MaybeAiImageModel {
+ const model = MAYBEAI_DEFAULT_IMAGE_MODEL_PRIORITY.find((candidate) => supportsMaybeAiRatio(candidate, ratio));
+ if (!model) {
+ throw new ArgumentError(
+ `No default image model supports ratio: ${ratio}`,
+ `Try one of the supported ratios for default models: ${listMaybeAiImageModelProfiles()
+ .filter((profile) => profile.priority !== null)
+ .flatMap((profile) => profile.supportedRatios)
+ .filter((item, index, list) => list.indexOf(item) === index)
+ .join(', ')}`,
+ );
+ }
+ return model;
+}
+
+export function assertMaybeAiModelSupportsRatio(model: MaybeAiImageModel, ratio: MaybeAiAspectRatio | undefined): void {
+ if (supportsMaybeAiRatio(model, ratio)) return;
+ const profile = getMaybeAiImageModelProfile(model);
+ throw new ArgumentError(
+ `Model ${model} does not support ratio ${ratio}`,
+ `Supported ratios for ${model}: ${profile.supportedRatios.join(', ')}`,
+ );
+}
diff --git a/clis/maybeai-image-app/models.ts b/clis/maybeai-image-app/models.ts
new file mode 100644
index 000000000..12f2b91e3
--- /dev/null
+++ b/clis/maybeai-image-app/models.ts
@@ -0,0 +1,35 @@
+import { cli, Strategy } from '@jackwener/opencli/registry';
+import { ArgumentError } from '@jackwener/opencli/errors';
+import { MAYBEAI_IMAGE_MODELS, type MaybeAiImageModel } from './profiles.js';
+import { getMaybeAiImageModelProfile, listMaybeAiImageModelProfiles } from './model-profiles.js';
+
+cli({
+ site: 'maybeai-image-app',
+ name: 'models',
+ description: 'Show MaybeAI image model priority and official supported aspect ratios',
+ strategy: Strategy.PUBLIC,
+ browser: false,
+ defaultFormat: 'json',
+ args: [
+ {
+ name: 'model',
+ positional: true,
+ required: false,
+ choices: [...MAYBEAI_IMAGE_MODELS],
+ help: 'Image model to inspect',
+ },
+ ],
+ func: async (_page, kwargs) => {
+ if (typeof kwargs.model === 'string') {
+ const model = kwargs.model as MaybeAiImageModel;
+ if (!MAYBEAI_IMAGE_MODELS.includes(model)) {
+ throw new ArgumentError(
+ `Invalid model: ${model}`,
+ `Allowed model values: ${MAYBEAI_IMAGE_MODELS.join(', ')}`,
+ );
+ }
+ return getMaybeAiImageModelProfile(model);
+ }
+ return listMaybeAiImageModelProfiles();
+ },
+});
diff --git a/clis/maybeai-image-app/options.ts b/clis/maybeai-image-app/options.ts
new file mode 100644
index 000000000..8598d2524
--- /dev/null
+++ b/clis/maybeai-image-app/options.ts
@@ -0,0 +1,24 @@
+import { cli, Strategy } from '@jackwener/opencli/registry';
+import { getMaybeAiOptions, type MaybeAiOptionKind } from './profiles.js';
+
+cli({
+ site: 'maybeai-image-app',
+ name: 'options',
+ description: 'List supported MaybeAI platforms, countries/regions, categories, angles, ratios, resolutions, models, and image kinds',
+ strategy: Strategy.PUBLIC,
+ browser: false,
+ defaultFormat: 'json',
+ args: [
+ {
+ name: 'kind',
+ positional: true,
+ required: false,
+ choices: ['platform', 'country', 'angle', 'category', 'ratio', 'resolution', 'model', 'image-kind'],
+ help: 'Option kind to list',
+ },
+ ],
+ func: async (_page, kwargs) => {
+ const kind = typeof kwargs.kind === 'string' ? kwargs.kind as MaybeAiOptionKind : undefined;
+ return getMaybeAiOptions(kind);
+ },
+});
diff --git a/clis/maybeai-image-app/payload.ts b/clis/maybeai-image-app/payload.ts
new file mode 100644
index 000000000..adb2018bb
--- /dev/null
+++ b/clis/maybeai-image-app/payload.ts
@@ -0,0 +1,31 @@
+import { cli, Strategy } from '@jackwener/opencli/registry';
+import { getMaybeAiGeneratedImageApp, toWorkflowVariables } from './catalog.js';
+import { readJsonObjectInput } from './input.js';
+
+cli({
+ site: 'maybeai-image-app',
+ name: 'payload',
+ description: 'Build workflow variables from normalized MaybeAI generated-image CLI input',
+ strategy: Strategy.PUBLIC,
+ browser: false,
+ defaultFormat: 'json',
+ args: [
+ { name: 'app', positional: true, required: true, help: 'MaybeAI app id, e.g. gen-main' },
+ { name: 'json', help: 'Inline JSON payload using normalized CLI keys' },
+ { name: 'file', help: 'Path to a JSON payload file' },
+ ],
+ func: async (_page, kwargs) => {
+ const app = getMaybeAiGeneratedImageApp(String(kwargs.app));
+ const input = readJsonObjectInput(
+ typeof kwargs.file === 'string' ? kwargs.file : undefined,
+ typeof kwargs.json === 'string' ? kwargs.json : undefined,
+ );
+
+ return {
+ app: app.id,
+ title: app.title,
+ variables: toWorkflowVariables(app, input),
+ outputSchema: app.output,
+ };
+ },
+});
diff --git a/clis/maybeai-image-app/platform-profiles.ts b/clis/maybeai-image-app/platform-profiles.ts
new file mode 100644
index 000000000..1252ba2e3
--- /dev/null
+++ b/clis/maybeai-image-app/platform-profiles.ts
@@ -0,0 +1,274 @@
+import {
+ MAYBEAI_DEFAULT_IMAGE_MODEL,
+ type MaybeAiAngle,
+ type MaybeAiAspectRatio,
+ type MaybeAiImageKind,
+ type MaybeAiImageModel,
+ type MaybeAiPlatform,
+ type MaybeAiResolution,
+} from './profiles.js';
+
+export interface MaybeAiRuleSource {
+ label: string;
+ url?: string;
+ confidence: 'official' | 'third-party' | 'inferred';
+}
+
+export interface MaybeAiPlatformRule {
+ platform: MaybeAiPlatform;
+ defaultRatio: MaybeAiAspectRatio;
+ ratiosByKind: Partial>;
+ allowedRatios: MaybeAiAspectRatio[];
+ defaultResolution: MaybeAiResolution;
+ defaultAngles: MaybeAiAngle[];
+ defaultEngine: MaybeAiImageModel;
+ notes: string[];
+ sources: MaybeAiRuleSource[];
+}
+
+export const DEFAULT_MAYBEAI_ANGLES: MaybeAiAngle[] = ['Frontal', 'Lateral', 'Posterior'];
+
+export const MAYBEAI_PLATFORM_RULES: Record = {
+ Amazon: {
+ platform: 'Amazon',
+ defaultRatio: '1:1',
+ ratiosByKind: {
+ main: '1:1',
+ scene: '1:1',
+ detail: '1:1',
+ 'multi-angle': '1:1',
+ model: '1:1',
+ },
+ allowedRatios: ['1:1', '4:5', '3:4', '4:3'],
+ defaultResolution: '2K',
+ defaultAngles: DEFAULT_MAYBEAI_ANGLES,
+ defaultEngine: MAYBEAI_DEFAULT_IMAGE_MODEL,
+ notes: ['主图优先方图;白底、主体占比高、无多余文字/水印更稳妥。'],
+ sources: [
+ {
+ label: 'Amazon product photo guidance',
+ url: 'https://sell.amazon.com/blog/product-photos',
+ confidence: 'official',
+ },
+ ],
+ },
+ Temu: {
+ platform: 'Temu',
+ defaultRatio: '1:1',
+ ratiosByKind: {
+ main: '1:1',
+ scene: '1:1',
+ detail: '1:1',
+ 'multi-angle': '1:1',
+ model: '1:1',
+ },
+ allowedRatios: ['1:1', '4:5', '3:4'],
+ defaultResolution: '2K',
+ defaultAngles: DEFAULT_MAYBEAI_ANGLES,
+ defaultEngine: MAYBEAI_DEFAULT_IMAGE_MODEL,
+ notes: ['未找到稳定公开官方比例页,按跨境电商商品主图保守使用 1:1。'],
+ sources: [{ label: 'Conservative ecommerce square-image profile', confidence: 'inferred' }],
+ },
+ TikTokShop: {
+ platform: 'TikTokShop',
+ defaultRatio: '1:1',
+ ratiosByKind: {
+ main: '1:1',
+ scene: '1:1',
+ detail: '1:1',
+ 'multi-angle': '1:1',
+ model: '1:1',
+ social: '9:16',
+ story: '9:16',
+ },
+ allowedRatios: ['1:1', '4:5', '9:16'],
+ defaultResolution: '2K',
+ defaultAngles: DEFAULT_MAYBEAI_ANGLES,
+ defaultEngine: MAYBEAI_DEFAULT_IMAGE_MODEL,
+ notes: ['商品图走 1:1;短视频/内容化素材走 9:16。'],
+ sources: [
+ {
+ label: 'TikTok Shop Seller University product listing guidance',
+ url: 'https://seller-us.tiktok.com/university/essay?default_language=en&identity=1&knowledge_id=3196690250417921',
+ confidence: 'official',
+ },
+ ],
+ },
+ Shopee: {
+ platform: 'Shopee',
+ defaultRatio: '1:1',
+ ratiosByKind: {
+ main: '1:1',
+ scene: '1:1',
+ detail: '1:1',
+ 'multi-angle': '1:1',
+ model: '1:1',
+ },
+ allowedRatios: ['1:1', '4:5', '3:4'],
+ defaultResolution: '2K',
+ defaultAngles: DEFAULT_MAYBEAI_ANGLES,
+ defaultEngine: MAYBEAI_DEFAULT_IMAGE_MODEL,
+ notes: ['商品主图按 Shopee 常见方图规范处理。'],
+ sources: [
+ {
+ label: 'Shopee marketplace image requirements summary',
+ url: 'https://support.channelengine.com/hc/en-us/articles/4409503364509-Shopee-marketplace-guide',
+ confidence: 'third-party',
+ },
+ ],
+ },
+ Lazada: {
+ platform: 'Lazada',
+ defaultRatio: '1:1',
+ ratiosByKind: {
+ main: '1:1',
+ scene: '1:1',
+ detail: '1:1',
+ 'multi-angle': '1:1',
+ model: '1:1',
+ },
+ allowedRatios: ['1:1', '4:5', '3:4'],
+ defaultResolution: '2K',
+ defaultAngles: DEFAULT_MAYBEAI_ANGLES,
+ defaultEngine: MAYBEAI_DEFAULT_IMAGE_MODEL,
+ notes: ['商品图按 Lazada 方图/高分辨率商品图处理。'],
+ sources: [
+ {
+ label: 'Lazada marketplace image requirements summary',
+ url: 'https://support.channelengine.com/hc/en-us/articles/4409503569565-Lazada-marketplace-guide',
+ confidence: 'third-party',
+ },
+ ],
+ },
+ Hacoo: {
+ platform: 'Hacoo',
+ defaultRatio: '1:1',
+ ratiosByKind: {
+ main: '1:1',
+ scene: '4:5',
+ detail: '1:1',
+ 'multi-angle': '1:1',
+ model: '4:5',
+ social: '4:5',
+ story: '9:16',
+ },
+ allowedRatios: ['1:1', '4:5', '3:4', '9:16'],
+ defaultResolution: '2K',
+ defaultAngles: DEFAULT_MAYBEAI_ANGLES,
+ defaultEngine: MAYBEAI_DEFAULT_IMAGE_MODEL,
+ notes: ['未找到公开卖家图片规范,按移动端社交电商保守配置。'],
+ sources: [{ label: 'Conservative mobile social-commerce profile', confidence: 'inferred' }],
+ },
+ XiaoHongShu: {
+ platform: 'XiaoHongShu',
+ defaultRatio: '3:4',
+ ratiosByKind: {
+ main: '3:4',
+ scene: '3:4',
+ detail: '3:4',
+ 'multi-angle': '3:4',
+ model: '3:4',
+ social: '3:4',
+ story: '9:16',
+ },
+ allowedRatios: ['3:4', '1:1', '4:5', '9:16'],
+ defaultResolution: '2K',
+ defaultAngles: DEFAULT_MAYBEAI_ANGLES,
+ defaultEngine: MAYBEAI_DEFAULT_IMAGE_MODEL,
+ notes: ['笔记/封面优先竖图;商品化素材默认 3:4 以适配信息流。'],
+ sources: [{ label: 'Common Xiaohongshu note-cover practice', confidence: 'inferred' }],
+ },
+ Instagram: {
+ platform: 'Instagram',
+ defaultRatio: '4:5',
+ ratiosByKind: {
+ main: '1:1',
+ scene: '4:5',
+ detail: '4:5',
+ 'multi-angle': '4:5',
+ model: '4:5',
+ social: '4:5',
+ story: '9:16',
+ },
+ allowedRatios: ['1:1', '4:5', '9:16', '16:9'],
+ defaultResolution: '2K',
+ defaultAngles: DEFAULT_MAYBEAI_ANGLES,
+ defaultEngine: MAYBEAI_DEFAULT_IMAGE_MODEL,
+ notes: ['Feed 优先 4:5,Story/Reels 走 9:16,商品卡片可用 1:1。'],
+ sources: [
+ {
+ label: 'Meta ads guide for Instagram placements',
+ url: 'https://www.facebook.com/business/ads-guide/',
+ confidence: 'official',
+ },
+ ],
+ },
+ Etsy: {
+ platform: 'Etsy',
+ defaultRatio: '4:3',
+ ratiosByKind: {
+ main: '4:3',
+ scene: '4:3',
+ detail: '4:3',
+ 'multi-angle': '4:3',
+ model: '4:3',
+ social: '1:1',
+ },
+ allowedRatios: ['4:3', '1:1', '3:4', '4:5'],
+ defaultResolution: '2K',
+ defaultAngles: DEFAULT_MAYBEAI_ANGLES,
+ defaultEngine: MAYBEAI_DEFAULT_IMAGE_MODEL,
+ notes: ['Listing 图优先高分辨率横图/方图;首图裁切时保留主体安全边距。'],
+ sources: [
+ {
+ label: 'Etsy listing image best practices',
+ url: 'https://help.etsy.com/hc/en-us/articles/115015663347-How-to-Add-Listing-Photos',
+ confidence: 'official',
+ },
+ ],
+ },
+ Taobao: {
+ platform: 'Taobao',
+ defaultRatio: '1:1',
+ ratiosByKind: {
+ main: '1:1',
+ scene: '3:4',
+ detail: '3:4',
+ 'multi-angle': '1:1',
+ model: '3:4',
+ social: '3:4',
+ },
+ allowedRatios: ['1:1', '3:4', '4:5'],
+ defaultResolution: '2K',
+ defaultAngles: DEFAULT_MAYBEAI_ANGLES,
+ defaultEngine: MAYBEAI_DEFAULT_IMAGE_MODEL,
+ notes: ['主图走 1:1;详情/模特展示按移动端长图习惯走 3:4。'],
+ sources: [{ label: 'Common Taobao merchant image practice', confidence: 'inferred' }],
+ },
+ Pinduoduo: {
+ platform: 'Pinduoduo',
+ defaultRatio: '1:1',
+ ratiosByKind: {
+ main: '1:1',
+ scene: '1:1',
+ detail: '3:4',
+ 'multi-angle': '1:1',
+ model: '3:4',
+ social: '1:1',
+ },
+ allowedRatios: ['1:1', '3:4', '4:5'],
+ defaultResolution: '2K',
+ defaultAngles: DEFAULT_MAYBEAI_ANGLES,
+ defaultEngine: MAYBEAI_DEFAULT_IMAGE_MODEL,
+ notes: ['主图走 1:1;详情/服饰模特图可走 3:4。'],
+ sources: [{ label: 'Common Pinduoduo merchant image practice', confidence: 'inferred' }],
+ },
+};
+
+export function getMaybeAiPlatformRule(platform: MaybeAiPlatform): MaybeAiPlatformRule {
+ return MAYBEAI_PLATFORM_RULES[platform];
+}
+
+export function listMaybeAiPlatformRules(): MaybeAiPlatformRule[] {
+ return Object.values(MAYBEAI_PLATFORM_RULES);
+}
diff --git a/clis/maybeai-image-app/profiles.ts b/clis/maybeai-image-app/profiles.ts
new file mode 100644
index 000000000..c78adfbe3
--- /dev/null
+++ b/clis/maybeai-image-app/profiles.ts
@@ -0,0 +1,165 @@
+import { ArgumentError } from '@jackwener/opencli/errors';
+
+export const MAYBEAI_PLATFORMS = [
+ 'Amazon',
+ 'Temu',
+ 'TikTokShop',
+ 'Shopee',
+ 'Lazada',
+ 'Hacoo',
+ 'XiaoHongShu',
+ 'Instagram',
+ 'Etsy',
+ 'Taobao',
+ 'Pinduoduo',
+] as const;
+
+export type MaybeAiPlatform = typeof MAYBEAI_PLATFORMS[number];
+
+export const MAYBEAI_COUNTRIES_AND_REGIONS = [
+ 'China',
+ 'Malaysia',
+ 'Korea',
+ 'Southeast Asia',
+ 'South America',
+ 'Indonesia',
+ 'Thailand',
+ 'Central Europe',
+ 'Western Europe',
+ 'Northern Europe',
+ 'West Asia',
+ 'North America',
+ 'Africa',
+ 'Japan',
+ 'Russia',
+] as const;
+
+export type MaybeAiCountryOrRegion = typeof MAYBEAI_COUNTRIES_AND_REGIONS[number];
+
+export const MAYBEAI_ANGLES = [
+ 'Frontal',
+ 'Lateral',
+ 'Posterior',
+ 'Three-Quarter',
+ 'Top-Down',
+ 'Macro Detail',
+] as const;
+
+export type MaybeAiAngle = typeof MAYBEAI_ANGLES[number];
+
+export const MAYBEAI_CATEGORIES = [
+ 'Bags & Luggage',
+ 'Beauty & Personal Care',
+ "Children's Clothing",
+ 'Home Decor',
+ 'Home Textiles',
+ "Men's Clothing",
+ "Men's Shoes",
+ "Women's Clothing",
+ "Women's Shoes",
+ 'Accessories',
+ 'Electronics',
+ 'Toys',
+ 'Furniture & Home Improvement',
+ 'Appliances & Digital',
+ 'Sports & Outdoors',
+ 'Maternity & Trendy Toys',
+ 'Cleaning & Pets',
+ 'Automotive & Travel',
+ 'Food & Fresh',
+ 'Office & Stationery',
+ 'Books & Flowers',
+ 'Watches & Jewelry',
+] as const;
+
+export type MaybeAiCategory = typeof MAYBEAI_CATEGORIES[number];
+
+export const MAYBEAI_ASPECT_RATIOS = [
+ 'auto',
+ '21:9',
+ '16:9',
+ '3:2',
+ '4:3',
+ '5:4',
+ '1:1',
+ '4:5',
+ '3:4',
+ '2:3',
+ '9:16',
+ '4:1',
+ '1:4',
+ '8:1',
+ '1:8',
+] as const;
+
+export type MaybeAiAspectRatio = typeof MAYBEAI_ASPECT_RATIOS[number];
+
+export const MAYBEAI_RESOLUTIONS = ['1K', '2K', '4K'] as const;
+
+export type MaybeAiResolution = typeof MAYBEAI_RESOLUTIONS[number];
+
+export const MAYBEAI_IMAGE_MODELS = [
+ 'google/gemini-3.1-flash-image-preview',
+ 'fal-ai/nano-banana-2/edit',
+ 'google/gemini-3-pro-image-preview',
+ 'fal-ai/nano-banana-pro/edit',
+ 'fal-ai/gpt-image-1.5/edit',
+ 'fal-ai/qwen-image-edit-2511',
+] as const;
+
+export const MAYBEAI_DEFAULT_IMAGE_MODEL_PRIORITY = [
+ 'google/gemini-3.1-flash-image-preview',
+ 'fal-ai/nano-banana-2/edit',
+ 'google/gemini-3-pro-image-preview',
+ 'fal-ai/nano-banana-pro/edit',
+] as const;
+
+export type MaybeAiImageModel = typeof MAYBEAI_IMAGE_MODELS[number];
+
+export const MAYBEAI_DEFAULT_IMAGE_MODEL: MaybeAiImageModel = 'google/gemini-3.1-flash-image-preview';
+
+export const MAYBEAI_IMAGE_KINDS = [
+ 'main',
+ 'scene',
+ 'detail',
+ 'multi-angle',
+ 'model',
+ 'social',
+ 'story',
+ 'edit',
+] as const;
+
+export type MaybeAiImageKind = typeof MAYBEAI_IMAGE_KINDS[number];
+
+export type MaybeAiOptionKind = 'platform' | 'country' | 'angle' | 'category' | 'ratio' | 'resolution' | 'model' | 'image-kind';
+
+const OPTION_VALUES: Record = {
+ platform: MAYBEAI_PLATFORMS,
+ country: MAYBEAI_COUNTRIES_AND_REGIONS,
+ angle: MAYBEAI_ANGLES,
+ category: MAYBEAI_CATEGORIES,
+ ratio: MAYBEAI_ASPECT_RATIOS,
+ resolution: MAYBEAI_RESOLUTIONS,
+ model: MAYBEAI_IMAGE_MODELS,
+ 'image-kind': MAYBEAI_IMAGE_KINDS,
+};
+
+export function getMaybeAiOptions(kind?: MaybeAiOptionKind): Record {
+ if (kind) return { [kind]: OPTION_VALUES[kind] };
+ return OPTION_VALUES;
+}
+
+export function validateMaybeAiOption(kind: MaybeAiOptionKind, value: unknown, fieldName: string): void {
+ const allowed = OPTION_VALUES[kind];
+ const values = Array.isArray(value) ? value : [value];
+ const invalid = values
+ .filter((item) => item !== undefined && item !== null && item !== '')
+ .filter((item) => typeof item !== 'string' || !allowed.includes(item));
+
+ if (invalid.length > 0) {
+ throw new ArgumentError(
+ `Invalid ${fieldName}: ${invalid.join(', ')}`,
+ `Allowed ${fieldName} values: ${allowed.join(', ')}`,
+ );
+ }
+}
diff --git a/clis/maybeai-image-app/resolve.ts b/clis/maybeai-image-app/resolve.ts
new file mode 100644
index 000000000..4231692ba
--- /dev/null
+++ b/clis/maybeai-image-app/resolve.ts
@@ -0,0 +1,47 @@
+import { cli, Strategy } from '@jackwener/opencli/registry';
+import { readJsonObjectInput, mergeDefinedCliValues } from './input.js';
+import { MAYBEAI_IMAGE_KINDS } from './profiles.js';
+import { resolveMaybeAiGeneratedImageInput } from './resolver.js';
+
+cli({
+ site: 'maybeai-image-app',
+ name: 'resolve',
+ description: 'Resolve a MaybeAI generated-image app payload with platform-aware defaults before workflow execution',
+ strategy: Strategy.PUBLIC,
+ browser: false,
+ defaultFormat: 'json',
+ args: [
+ { name: 'app', positional: true, required: true, help: 'MaybeAI app id, e.g. gen-main' },
+ { name: 'json', help: 'Inline JSON payload using normalized CLI keys' },
+ { name: 'file', help: 'Path to a JSON payload file' },
+ { name: 'platform', help: 'Target platform, e.g. Amazon, Shopee, XiaoHongShu' },
+ { name: 'image-kind', choices: [...MAYBEAI_IMAGE_KINDS], help: 'Image kind for platform ratio adaptation' },
+ { name: 'market', help: 'Target country/region' },
+ { name: 'category', help: 'Product category' },
+ { name: 'ratio', help: 'Override aspect ratio' },
+ { name: 'resolution', help: 'Override resolution' },
+ { name: 'engine', help: 'Override Shell image model' },
+ { name: 'prompt', help: 'Extra generation requirements' },
+ ],
+ func: async (_page, kwargs) => {
+ const baseInput = readJsonObjectInput(
+ typeof kwargs.file === 'string' ? kwargs.file : undefined,
+ typeof kwargs.json === 'string' ? kwargs.json : undefined,
+ );
+ const input = mergeDefinedCliValues(baseInput, kwargs, [
+ 'platform',
+ 'market',
+ 'category',
+ 'ratio',
+ 'resolution',
+ 'engine',
+ 'prompt',
+ ]);
+
+ if (typeof kwargs['image-kind'] === 'string') {
+ input.imageKind = kwargs['image-kind'];
+ }
+
+ return resolveMaybeAiGeneratedImageInput(String(kwargs.app), input);
+ },
+});
diff --git a/clis/maybeai-image-app/resolver.ts b/clis/maybeai-image-app/resolver.ts
new file mode 100644
index 000000000..c36c01b3c
--- /dev/null
+++ b/clis/maybeai-image-app/resolver.ts
@@ -0,0 +1,325 @@
+import { ArgumentError } from '@jackwener/opencli/errors';
+import {
+ getMaybeAiGeneratedImageApp,
+ toWorkflowVariables,
+ type MaybeAiGeneratedImageApp,
+} from './catalog.js';
+import {
+ MAYBEAI_IMAGE_KINDS,
+ MAYBEAI_PLATFORMS,
+ validateMaybeAiOption,
+ type MaybeAiAspectRatio,
+ type MaybeAiImageKind,
+ type MaybeAiImageModel,
+ type MaybeAiPlatform,
+} from './profiles.js';
+import { getMaybeAiPlatformRule, type MaybeAiPlatformRule } from './platform-profiles.js';
+import {
+ assertMaybeAiModelSupportsRatio,
+ getMaybeAiImageModelProfile,
+ selectMaybeAiDefaultModelForRatio,
+} from './model-profiles.js';
+
+const APP_IMAGE_KIND: Record = {
+ 'try-on': 'model',
+ 'change-model': 'model',
+ 'mix-match': 'model',
+ 'change-action': 'model',
+ 'change-product': 'scene',
+ 'change-background': 'scene',
+ 'gen-main': 'main',
+ 'gen-scene': 'scene',
+ 'gen-details': 'detail',
+ 'details-selling-points': 'detail',
+ 'add-selling-points': 'detail',
+ 'gen-multi-angles': 'multi-angle',
+ 'gen-size-compare': 'detail',
+ 'creative-image-generation': 'social',
+ 'pattern-extraction': 'edit',
+ 'pattern-fission': 'edit',
+ 'scene-fission': 'scene',
+ '3d-from-2d': 'edit',
+ 'product-modification': 'edit',
+ 'change-color': 'edit',
+ 'remove-background': 'main',
+ 'remove-watermark': 'edit',
+ 'remove-face': 'edit',
+};
+
+interface MaybeAiAppPolicy {
+ platformDefaults?: boolean;
+ fixedDefaults?: Partial>;
+ lockedFields?: string[];
+}
+
+const APP_POLICIES: Record = {
+ 'pattern-extraction': {
+ platformDefaults: false,
+ fixedDefaults: { engine: 'google/gemini-3-pro-image-preview', background: ' ' },
+ lockedFields: ['engine'],
+ },
+ 'pattern-fission': {
+ platformDefaults: false,
+ fixedDefaults: { engine: 'google/gemini-3-pro-image-preview', background: ' ' },
+ lockedFields: ['engine'],
+ },
+ 'scene-fission': {
+ platformDefaults: false,
+ fixedDefaults: { engine: 'google/gemini-3-pro-image-preview' },
+ lockedFields: ['engine'],
+ },
+ '3d-from-2d': {
+ platformDefaults: false,
+ fixedDefaults: { engine: 'google/gemini-3-pro-image-preview' },
+ lockedFields: ['engine'],
+ },
+ 'product-modification': {
+ platformDefaults: false,
+ fixedDefaults: { engine: 'google/gemini-3-pro-image-preview' },
+ lockedFields: ['engine'],
+ },
+ 'change-color': {
+ platformDefaults: false,
+ },
+ 'remove-background': {
+ platformDefaults: false,
+ fixedDefaults: { engine: 'google/gemini-3-pro-image-preview', background: ' ' },
+ lockedFields: ['engine'],
+ },
+ 'remove-watermark': {
+ platformDefaults: false,
+ fixedDefaults: { engine: 'google/gemini-3-pro-image-preview' },
+ lockedFields: ['engine'],
+ },
+ 'remove-face': {
+ platformDefaults: false,
+ },
+};
+
+export interface MaybeAiGeneratedImageResolution {
+ app: string;
+ title: string;
+ imageKind: MaybeAiImageKind;
+ input: Record;
+ appliedDefaults: Record;
+ modelProfile?: {
+ model: MaybeAiImageModel;
+ priority: number | null;
+ supportedRatios: MaybeAiAspectRatio[];
+ };
+ platformProfile?: {
+ platform: MaybeAiPlatform;
+ defaultRatio: MaybeAiAspectRatio;
+ ratio: MaybeAiAspectRatio;
+ allowedRatios: MaybeAiAspectRatio[];
+ resolution: string;
+ sourceConfidence: string[];
+ notes: string[];
+ };
+ warnings: string[];
+ variables: Array<{ name: string; default_value: unknown }>;
+ outputSchema: MaybeAiGeneratedImageApp['output'];
+}
+
+export function inferMaybeAiImageKind(appId: string): MaybeAiImageKind {
+ return APP_IMAGE_KIND[appId] ?? 'edit';
+}
+
+export function resolveMaybeAiGeneratedImageInput(appId: string, rawInput: Record): MaybeAiGeneratedImageResolution {
+ const app = getMaybeAiGeneratedImageApp(appId);
+ const warnings: string[] = [];
+ const appliedDefaults: Record = {};
+ const input = normalizeAliases(rawInput);
+ const imageKind = resolveImageKind(app.id, input);
+ const appPolicy = APP_POLICIES[app.id] ?? {};
+ const platform = resolvePlatform(input);
+ const platformRule = platform ? getMaybeAiPlatformRule(platform) : undefined;
+ const canApplyPlatformDefaults = platformRule !== undefined && appPolicy.platformDefaults !== false;
+
+ delete input.kind;
+ delete input.imageKind;
+
+ if (!hasField(app, 'platform') && input.platform !== undefined) {
+ delete input.platform;
+ warnings.push('platform is used for CLI adaptation only; this Shell app has no platform backend field.');
+ }
+
+ applyFixedDefaults(app, input, appPolicy, appliedDefaults);
+
+ if (canApplyPlatformDefaults && platformRule) {
+ applyPlatformDefaults(app, input, imageKind, platformRule, appliedDefaults, warnings);
+ } else if (platformRule && appPolicy.platformDefaults === false) {
+ warnings.push(`${app.id} keeps Shell app defaults; platform ratio defaults were not applied.`);
+ }
+
+ const resolvedRatio = readRatio(input.ratio);
+ const engine = resolveEngine(app, input, appPolicy, resolvedRatio, appliedDefaults);
+ if (engine) assertMaybeAiModelSupportsRatio(engine, resolvedRatio);
+
+ if (hasField(app, 'angles') && input.angles === undefined) {
+ const angles = platformRule?.defaultAngles ?? ['Frontal', 'Lateral', 'Posterior'];
+ input.angles = angles;
+ appliedDefaults.angles = angles;
+ }
+
+ const variables = toWorkflowVariables(app, input);
+ const outputRatio = resolvedRatio ?? (canApplyPlatformDefaults ? platformRule?.defaultRatio : undefined);
+
+ return {
+ app: app.id,
+ title: app.title,
+ imageKind,
+ input,
+ appliedDefaults,
+ modelProfile: engine ? {
+ model: engine,
+ priority: getMaybeAiImageModelProfile(engine).priority,
+ supportedRatios: getMaybeAiImageModelProfile(engine).supportedRatios,
+ } : undefined,
+ platformProfile: platformRule && outputRatio
+ ? {
+ platform: platformRule.platform,
+ defaultRatio: platformRule.defaultRatio,
+ ratio: outputRatio,
+ allowedRatios: platformRule.allowedRatios,
+ resolution: String(input.resolution ?? platformRule.defaultResolution),
+ sourceConfidence: Array.from(new Set(platformRule.sources.map((source) => source.confidence))),
+ notes: platformRule.notes,
+ }
+ : undefined,
+ warnings,
+ variables,
+ outputSchema: app.output,
+ };
+}
+
+function normalizeAliases(rawInput: Record): Record {
+ const input = { ...rawInput };
+ if (input.engine === undefined && input.model !== undefined) {
+ input.engine = input.model;
+ }
+ delete input.model;
+ return input;
+}
+
+function resolveImageKind(appId: string, input: Record): MaybeAiImageKind {
+ const rawKind = input.imageKind ?? input.kind;
+ if (rawKind === undefined || rawKind === null || rawKind === '') {
+ return inferMaybeAiImageKind(appId);
+ }
+ if (typeof rawKind !== 'string' || !MAYBEAI_IMAGE_KINDS.includes(rawKind as MaybeAiImageKind)) {
+ throw new ArgumentError(
+ `Invalid image kind: ${String(rawKind)}`,
+ `Allowed image kinds: ${MAYBEAI_IMAGE_KINDS.join(', ')}`,
+ );
+ }
+ return rawKind as MaybeAiImageKind;
+}
+
+function resolvePlatform(input: Record): MaybeAiPlatform | undefined {
+ const rawPlatform = input.platform;
+ if (rawPlatform === undefined || rawPlatform === null || rawPlatform === '') return undefined;
+ if (typeof rawPlatform !== 'string' || !MAYBEAI_PLATFORMS.includes(rawPlatform as MaybeAiPlatform)) {
+ validateMaybeAiOption('platform', rawPlatform, 'platform');
+ }
+ return rawPlatform as MaybeAiPlatform;
+}
+
+function applyPlatformDefaults(
+ app: MaybeAiGeneratedImageApp,
+ input: Record,
+ imageKind: MaybeAiImageKind,
+ rule: MaybeAiPlatformRule,
+ appliedDefaults: Record,
+ warnings: string[],
+): void {
+ if (hasField(app, 'ratio')) {
+ if (input.ratio === undefined || input.ratio === null || input.ratio === '' || input.ratio === 'auto') {
+ const ratio = rule.ratiosByKind[imageKind] ?? rule.defaultRatio;
+ input.ratio = ratio;
+ appliedDefaults.ratio = ratio;
+ } else {
+ const ratio = readRatio(input.ratio);
+ if (ratio && !rule.allowedRatios.includes(ratio)) {
+ warnings.push(`${rule.platform} ${imageKind} usually uses: ${rule.allowedRatios.join(', ')}; received ${ratio}.`);
+ }
+ }
+ }
+
+ if (hasField(app, 'resolution') && (input.resolution === undefined || input.resolution === null || input.resolution === '')) {
+ input.resolution = rule.defaultResolution;
+ appliedDefaults.resolution = rule.defaultResolution;
+ }
+}
+
+function applyFixedDefaults(
+ app: MaybeAiGeneratedImageApp,
+ input: Record,
+ policy: MaybeAiAppPolicy,
+ appliedDefaults: Record,
+): void {
+ if (!policy.fixedDefaults) return;
+
+ for (const [field, defaultValue] of Object.entries(policy.fixedDefaults)) {
+ if (!hasField(app, field)) continue;
+ const currentValue = input[field];
+ const isMissing = currentValue === undefined || currentValue === null || currentValue === '';
+ if (isMissing) {
+ input[field] = defaultValue;
+ appliedDefaults[field] = defaultValue;
+ continue;
+ }
+ if (policy.lockedFields?.includes(field) && currentValue !== defaultValue) {
+ throw new ArgumentError(
+ `${app.id} has fixed ${field}: ${String(defaultValue)}`,
+ `Remove ${field} from input or use ${field}=${String(defaultValue)}.`,
+ );
+ }
+ }
+}
+
+function resolveEngine(
+ app: MaybeAiGeneratedImageApp,
+ input: Record,
+ policy: MaybeAiAppPolicy,
+ ratio: MaybeAiAspectRatio | undefined,
+ appliedDefaults: Record,
+): MaybeAiImageModel | undefined {
+ if (!hasField(app, 'engine')) return undefined;
+
+ const fixedEngine = policy.fixedDefaults?.engine as MaybeAiImageModel | undefined;
+ const currentEngine = input.engine;
+
+ if (fixedEngine) {
+ if (currentEngine !== undefined && currentEngine !== null && currentEngine !== '' && currentEngine !== fixedEngine) {
+ throw new ArgumentError(
+ `${app.id} has fixed engine: ${fixedEngine}`,
+ `This app follows Shell defaults and cannot switch to ${String(currentEngine)}.`,
+ );
+ }
+ input.engine = fixedEngine;
+ if (currentEngine === undefined || currentEngine === null || currentEngine === '') {
+ appliedDefaults.engine = fixedEngine;
+ }
+ return fixedEngine;
+ }
+
+ if (currentEngine !== undefined && currentEngine !== null && currentEngine !== '') {
+ validateMaybeAiOption('model', currentEngine, 'engine');
+ return currentEngine as MaybeAiImageModel;
+ }
+
+ const engine = selectMaybeAiDefaultModelForRatio(ratio);
+ input.engine = engine;
+ appliedDefaults.engine = engine;
+ return engine;
+}
+
+function hasField(app: MaybeAiGeneratedImageApp, key: string): boolean {
+ return app.fields.some((field) => field.key === key);
+}
+
+function readRatio(value: unknown): MaybeAiAspectRatio | undefined {
+ if (typeof value !== 'string') return undefined;
+ return value as MaybeAiAspectRatio;
+}
diff --git a/clis/maybeai-image-app/rules.ts b/clis/maybeai-image-app/rules.ts
new file mode 100644
index 000000000..e424172ee
--- /dev/null
+++ b/clis/maybeai-image-app/rules.ts
@@ -0,0 +1,35 @@
+import { cli, Strategy } from '@jackwener/opencli/registry';
+import { ArgumentError } from '@jackwener/opencli/errors';
+import { MAYBEAI_PLATFORMS, type MaybeAiPlatform } from './profiles.js';
+import { getMaybeAiPlatformRule, listMaybeAiPlatformRules } from './platform-profiles.js';
+
+cli({
+ site: 'maybeai-image-app',
+ name: 'rules',
+ description: 'Show MaybeAI platform-aware image ratio, resolution, angle, and source rules',
+ strategy: Strategy.PUBLIC,
+ browser: false,
+ defaultFormat: 'json',
+ args: [
+ {
+ name: 'platform',
+ positional: true,
+ required: false,
+ choices: [...MAYBEAI_PLATFORMS],
+ help: 'Platform to inspect',
+ },
+ ],
+ func: async (_page, kwargs) => {
+ if (typeof kwargs.platform === 'string') {
+ const platform = kwargs.platform as MaybeAiPlatform;
+ if (!MAYBEAI_PLATFORMS.includes(platform)) {
+ throw new ArgumentError(
+ `Invalid platform: ${platform}`,
+ `Allowed platform values: ${MAYBEAI_PLATFORMS.join(', ')}`,
+ );
+ }
+ return getMaybeAiPlatformRule(platform);
+ }
+ return listMaybeAiPlatformRules();
+ },
+});
diff --git a/clis/maybeai-image-app/schema.ts b/clis/maybeai-image-app/schema.ts
new file mode 100644
index 000000000..eed3951fa
--- /dev/null
+++ b/clis/maybeai-image-app/schema.ts
@@ -0,0 +1,30 @@
+import { cli, Strategy } from '@jackwener/opencli/registry';
+import { getMaybeAiGeneratedImageApp } from './catalog.js';
+import { getMaybeAiOptions } from './profiles.js';
+import { inferMaybeAiImageKind } from './resolver.js';
+
+cli({
+ site: 'maybeai-image-app',
+ name: 'schema',
+ description: 'Show unified CLI schema and backend variable mapping for a MaybeAI generated-image app',
+ strategy: Strategy.PUBLIC,
+ browser: false,
+ defaultFormat: 'json',
+ args: [
+ { name: 'app', positional: true, required: true, help: 'MaybeAI app id, e.g. change-model' },
+ ],
+ func: async (_page, kwargs) => {
+ const app = getMaybeAiGeneratedImageApp(String(kwargs.app));
+ return {
+ id: app.id,
+ title: app.title,
+ group: app.group,
+ summary: app.summary,
+ sourceRef: app.sourceRef,
+ defaultImageKind: inferMaybeAiImageKind(app.id),
+ inputSchema: app.fields,
+ outputSchema: app.output,
+ options: getMaybeAiOptions(),
+ };
+ },
+});
diff --git a/clis/maybeai-image-app/workflow-client.ts b/clis/maybeai-image-app/workflow-client.ts
new file mode 100644
index 000000000..61cf1fd2b
--- /dev/null
+++ b/clis/maybeai-image-app/workflow-client.ts
@@ -0,0 +1,301 @@
+import { CliError } from '@jackwener/opencli/errors';
+
+export interface MaybeAiWorkflowAuth {
+ token: string;
+ userId: string;
+}
+
+export interface MaybeAiWorkflowClientOptions {
+ baseUrl: string;
+ auth: MaybeAiWorkflowAuth;
+ systemAuth?: MaybeAiWorkflowAuth;
+ service?: string;
+}
+
+export interface MaybeAiWorkflowRunOptions {
+ artifactId: string;
+ variables: Array<{ name: string; default_value: unknown }>;
+ appId: string;
+ title: string;
+ taskId?: string;
+ prevTaskId?: string;
+ useSystemAuth?: boolean;
+ service?: string;
+}
+
+interface WorkflowDetail {
+ id: string;
+ artifact_id: string;
+ variables?: Array<{ name?: string }>;
+ user_input?: Array<{ name?: string }>;
+}
+
+interface WorkflowRunBody {
+ artifact_id: string;
+ interaction: boolean;
+ task: string;
+ task_id: string;
+ prev_task_id?: string;
+ workflow_id: string;
+ variables: Array<{ name: string; default_value: unknown }>;
+ metadata: { case: string; title: string };
+ last_chunk_id?: string;
+ service?: string;
+}
+
+export function readMaybeAiWorkflowClientOptions(): MaybeAiWorkflowClientOptions {
+ const baseUrl = process.env.MAYBEAI_PLAYGROUND_URL || process.env.NEXT_PUBLIC_PLAYGROUND_URL;
+ const token = process.env.MAYBEAI_AUTH_TOKEN || process.env.MAYBEAI_TOKEN || process.env.AUTH_TOKEN;
+ const userId = process.env.MAYBEAI_USER_ID || process.env.USER_ID;
+ const systemToken = process.env.MAYBEAI_SYSTEM_TOKEN || process.env.BINGO_TOKEN;
+ const systemUserId = process.env.MAYBEAI_SYSTEM_USER_ID || process.env.BINGO_USER_ID;
+
+ if (!baseUrl) {
+ throw new CliError('CONFIG', 'Missing MAYBEAI_PLAYGROUND_URL', 'Set MAYBEAI_PLAYGROUND_URL=https://... before running image generation.');
+ }
+ if (!token || !userId) {
+ throw new CliError('CONFIG', 'Missing MaybeAI auth', 'Set MAYBEAI_AUTH_TOKEN and MAYBEAI_USER_ID before running image generation.');
+ }
+
+ return {
+ baseUrl: baseUrl.replace(/\/+$/, ''),
+ auth: { token, userId },
+ systemAuth: systemToken && systemUserId ? { token: systemToken, userId: systemUserId } : undefined,
+ service: process.env.MAYBEAI_SERVICE || 'e-commerce',
+ };
+}
+
+export class MaybeAiWorkflowClient {
+ constructor(private readonly options: MaybeAiWorkflowClientOptions) {}
+
+ async run(options: MaybeAiWorkflowRunOptions): Promise {
+ const workflowDetail = await this.fetchWorkflowDetail(options.artifactId);
+ const body: WorkflowRunBody = {
+ artifact_id: workflowDetail.artifact_id,
+ interaction: true,
+ task: '',
+ task_id: options.taskId || crypto.randomUUID(),
+ prev_task_id: options.prevTaskId,
+ workflow_id: workflowDetail.id,
+ variables: filterWorkflowVariables(workflowDetail, options.variables),
+ metadata: {
+ case: options.appId,
+ title: options.title,
+ },
+ last_chunk_id: undefined,
+ };
+
+ const service = options.service ?? this.options.service;
+ if (!options.useSystemAuth && service) {
+ body.service = service;
+ }
+
+ const auth = options.useSystemAuth
+ ? this.options.systemAuth ?? this.options.auth
+ : this.options.auth;
+
+ const response = await fetch(`${this.options.baseUrl}/api/v1/workflow/run`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${auth.token}`,
+ 'user-id': auth.userId,
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (!response.ok || !response.body) {
+ throw new CliError('HTTP', `Workflow run failed: ${response.status}`, await safeResponseText(response));
+ }
+
+ return readWorkflowStream(response, body);
+ }
+
+ private async fetchWorkflowDetail(artifactId: string): Promise {
+ const response = await fetch(`${this.options.baseUrl}/api/v1/workflow/detail/public`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ artifact_id: artifactId }),
+ });
+
+ if (!response.ok) {
+ throw new CliError('HTTP', `Workflow detail failed: ${response.status}`, await safeResponseText(response));
+ }
+
+ return response.json() as Promise;
+ }
+}
+
+export function buildSecondStepVariablesV2(
+ promptConfigs: Array>,
+ finalVariables: Array<{ name: string; default_value: unknown }>,
+ appId: string,
+ includeLlmModel: boolean,
+): Array<{ name: string; default_value: unknown }> {
+ const variableMap = new Map(finalVariables.map((item) => [item.name, item.default_value]));
+ const processedPromptConfigs = promptConfigs.map(normalizePromptConfig);
+
+ return [
+ {
+ name: 'variable:scalar:case',
+ default_value: appId,
+ },
+ {
+ name: 'variable:dataframe:input_data',
+ default_value: processedPromptConfigs,
+ },
+ ...(includeLlmModel && variableMap.has('variable:scalar:llm_model')
+ ? [{
+ name: 'variable:scalar:llm_model',
+ default_value: variableMap.get('variable:scalar:llm_model'),
+ }]
+ : []),
+ ];
+}
+
+export function extractGeneratedImages(results: unknown[], imageFields: string[]): Array<{ type: 'image'; url: string; raw: unknown }> {
+ const images: Array<{ type: 'image'; url: string; raw: unknown }> = [];
+ for (const item of results) {
+ if (!item || typeof item !== 'object') continue;
+ for (const field of imageFields) {
+ const value = getByPath(item as Record, field);
+ if (typeof value === 'string' && value.trim()) {
+ images.push({ type: 'image', url: value, raw: item });
+ break;
+ }
+ }
+ }
+ return images;
+}
+
+function filterWorkflowVariables(
+ workflowDetail: WorkflowDetail,
+ variables: Array<{ name: string; default_value: unknown }>,
+): Array<{ name: string; default_value: unknown }> {
+ const allowedNames = new Set();
+ for (const item of workflowDetail.variables ?? []) {
+ if (item.name) allowedNames.add(item.name);
+ }
+ for (const item of workflowDetail.user_input ?? []) {
+ if (item.name) allowedNames.add(item.name);
+ }
+ if (allowedNames.size === 0) return variables;
+ return variables.filter((item) => allowedNames.has(item.name));
+}
+
+async function readWorkflowStream(response: Response, body: WorkflowRunBody): Promise {
+ const reader = response.body?.getReader();
+ if (!reader) return [];
+
+ const decoder = new TextDecoder();
+ let buffer = '';
+ const dataflowOutput: unknown[] = [];
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const events = buffer.split(/\n\n/);
+ buffer = events.pop() ?? '';
+
+ for (const eventText of events) {
+ const eventData = parseSseData(eventText);
+ if (!eventData) continue;
+
+ const maybeOutput = parseWorkflowEvent(eventData, body);
+ if (maybeOutput?.type === 'output') {
+ dataflowOutput.push(...maybeOutput.data);
+ }
+ if (maybeOutput?.type === 'failed') {
+ throw new CliError('WORKFLOW', maybeOutput.message, `Task ID: ${body.task_id}`);
+ }
+ }
+ }
+
+ return dataflowOutput;
+}
+
+function parseSseData(eventText: string): string | null {
+ const lines = eventText
+ .split(/\r?\n/)
+ .filter((line) => line.startsWith('data:'))
+ .map((line) => line.slice(5).trimStart());
+ if (lines.length === 0) return null;
+ return lines.join('\n');
+}
+
+function parseWorkflowEvent(eventData: string, body: WorkflowRunBody): { type: 'output'; data: unknown[] } | { type: 'failed'; message: string } | null {
+ const json = JSON.parse(eventData) as Record;
+ if (json.type !== 'content') return null;
+ if (typeof json.id === 'string') body.last_chunk_id = json.id;
+
+ const data = json.data;
+ if (!data || typeof data !== 'object') return null;
+ const content = (data as Record).content;
+ if (typeof content !== 'string') return null;
+
+ const parsed = JSON.parse(content) as Record;
+ if (parsed.event_type === 'workflow_failed' || parsed.event_type === 'action_failed') {
+ return { type: 'failed', message: JSON.stringify(parsed).slice(-800) };
+ }
+
+ if (parsed.event_type !== 'dataflow_output' || typeof parsed.content !== 'string') {
+ return null;
+ }
+
+ const parsedContent = JSON.parse(parsed.content) as Record;
+ const output = parsedContent.output as Record | undefined;
+ if (!output) return null;
+
+ if (output.type === 'dataframe' && Array.isArray(output.data)) {
+ return { type: 'output', data: output.data };
+ }
+ if (output.type === 'scalar') {
+ return { type: 'output', data: [flattenScalarOutput(String(parsedContent.output_id ?? ''), output.data)] };
+ }
+ return null;
+}
+
+function flattenScalarOutput(outputId: string, data: unknown): Record {
+ const result: Record = {};
+ const normalizedOutputId = outputId.split(':').pop();
+ if (normalizedOutputId) result[normalizedOutputId] = data;
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
+ Object.assign(result, data as Record);
+ }
+ return result;
+}
+
+function normalizePromptConfig(item: Record): Record {
+ const result = { ...item };
+ for (const key of ['product_image_url', 'reference_image_url']) {
+ if (typeof result[key] !== 'string') continue;
+ try {
+ const parsed = JSON.parse(result[key]);
+ if (Array.isArray(parsed)) result[key] = parsed;
+ } catch {
+ // keep original string
+ }
+ }
+ if (typeof result.duration === 'string') {
+ const duration = Number.parseInt(result.duration, 10);
+ if (Number.isFinite(duration)) result.duration = duration;
+ }
+ return result;
+}
+
+function getByPath(item: Record, path: string): unknown {
+ return path.split('.').reduce((current, segment) => {
+ if (!current || typeof current !== 'object') return undefined;
+ return (current as Record)[segment];
+ }, item);
+}
+
+async function safeResponseText(response: Response): Promise {
+ try {
+ return await response.text();
+ } catch {
+ return '';
+ }
+}
diff --git a/clis/maybeai-image-app/workflow-profiles.ts b/clis/maybeai-image-app/workflow-profiles.ts
new file mode 100644
index 000000000..c95c923ee
--- /dev/null
+++ b/clis/maybeai-image-app/workflow-profiles.ts
@@ -0,0 +1,57 @@
+import { ArgumentError } from '@jackwener/opencli/errors';
+
+export type MaybeAiWorkflowMode = 'direct' | 'two-step-v2';
+
+export interface MaybeAiWorkflowProfile {
+ app: string;
+ mode: MaybeAiWorkflowMode;
+ promptArtifactId: string;
+ resultArtifactId: string;
+ service: string;
+}
+
+const workflowProfile = (
+ app: string,
+ promptArtifactId: string,
+ resultArtifactId: string,
+): MaybeAiWorkflowProfile => ({
+ app,
+ mode: promptArtifactId === resultArtifactId ? 'direct' : 'two-step-v2',
+ promptArtifactId,
+ resultArtifactId,
+ service: 'e-commerce',
+});
+
+export const MAYBEAI_WORKFLOW_PROFILES: Record = {
+ 'try-on': workflowProfile('try-on', '69d4d48587747a74ba79d84e', '694e206fc1c0b24dc831ad8b'),
+ 'change-model': workflowProfile('change-model', '694caf01b7c2c3990ca7b8bf', '694e206fc1c0b24dc831ad8b'),
+ 'mix-match': workflowProfile('mix-match', '694e437fb7c2c3990cab8603', '695b4b0a1189bc43eb96a480'),
+ 'change-action': workflowProfile('change-action', '69cb41fbd03ef955b10c1d9a', '694e206fc1c0b24dc831ad8b'),
+ 'change-product': workflowProfile('change-product', '694cb498c1c0b24dc82f4264', '694e206fc1c0b24dc831ad8b'),
+ 'change-background': workflowProfile('change-background', '69cb69222a1008a8834cfe60', '694e206fc1c0b24dc831ad8b'),
+ 'gen-main': workflowProfile('gen-main', '694cb52eb7c2c3990ca7e9b7', '694e206fc1c0b24dc831ad8b'),
+ 'gen-scene': workflowProfile('gen-scene', '694cb7f4b7c2c3990ca7f709', '694e206fc1c0b24dc831ad8b'),
+ 'gen-details': workflowProfile('gen-details', '694e63e8c1c0b24dc8337f72', '694e7d10c83c43d81d214a92'),
+ 'details-selling-points': workflowProfile('details-selling-points', '694e47f0b7c2c3990cabaadb', '694e7d10c83c43d81d214a92'),
+ 'add-selling-points': workflowProfile('add-selling-points', '694cbce8b7c2c3990ca8053a', '694e7d10c83c43d81d214a92'),
+ 'gen-multi-angles': workflowProfile('gen-multi-angles', '694e671eb7c2c3990cac9972', '69899d16de11a7737bfac704'),
+ 'gen-size-compare': workflowProfile('gen-size-compare', '694e6591b7c2c3990cac8f24', '694e7d10c83c43d81d214a92'),
+ 'creative-image-generation': workflowProfile('creative-image-generation', '6981b9fb92f155d6c596b031', '6981b9fb92f155d6c596b031'),
+ 'pattern-extraction': workflowProfile('pattern-extraction', '698066d65c435e0365a509df', '698066d65c435e0365a509df'),
+ 'pattern-fission': workflowProfile('pattern-fission', '69807f235c435e0365a5ab75', '69807f235c435e0365a5ab75'),
+ 'scene-fission': workflowProfile('scene-fission', '69818dbc641f9ed0ce150361', '69818dbc641f9ed0ce150361'),
+ '3d-from-2d': workflowProfile('3d-from-2d', '698083725c435e0365a5bc20', '698083725c435e0365a5bc20'),
+ 'product-modification': workflowProfile('product-modification', '698084bf23591ba38ae6321f', '698084bf23591ba38ae6321f'),
+ 'change-color': workflowProfile('change-color', '694cb9e2b7c2c3990ca7f9f1', '694e206fc1c0b24dc831ad8b'),
+ 'remove-background': workflowProfile('remove-background', '694cbafeb7c2c3990ca7fc41', '694e206fc1c0b24dc831ad8b'),
+ 'remove-watermark': workflowProfile('remove-watermark', '694cbba1b7c2c3990ca7fe83', '694e206fc1c0b24dc831ad8b'),
+ 'remove-face': workflowProfile('remove-face', '694cbc2db7c2c3990ca7ff29', '694e206fc1c0b24dc831ad8b'),
+};
+
+export function getMaybeAiWorkflowProfile(appId: string): MaybeAiWorkflowProfile {
+ const profile = MAYBEAI_WORKFLOW_PROFILES[appId];
+ if (!profile) {
+ throw new ArgumentError(`No workflow profile for maybeai-image-app app: ${appId}`);
+ }
+ return profile;
+}
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..980ca2d15
--- /dev/null
+++ b/clis/shopee/product-shopdora-download.test.ts
@@ -0,0 +1,397 @@
+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_DIALOG_SELECTOR,
+ EXPORT_REVIEW_BUTTON_SELECTOR,
+ DETAIL_FILTER_INPUT_SELECTOR,
+ TIME_PERIOD_START_INPUT_SELECTOR,
+ TIME_PERIOD_START_MONTH_OFFSET,
+ TIME_PERIOD_START_DAY_OFFSET,
+ CONFIRM_EXPORT_BUTTON_SELECTOR,
+ normalizeShopeeReviewUrl,
+ bindShopeeProductTab,
+ ensureShopeeProductPage,
+ buildEnsureCheckboxStateScript,
+ buildResolveTargetSelectorScript,
+ buildReadInputValueScript,
+ buildDispatchEnterOnInputScript,
+ computeShiftedDateFromInputValue,
+ buildWaitForExportReviewReadyScript,
+ setComputedTimePeriodStartValue,
+} =
+ 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!.timeoutSeconds).toBe(600);
+ expect(command!.columns).toEqual(['status', 'message', 'local_url', 'local_path', 'product_url', 'shopdora_login_message']);
+ 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('download-review-images-input');
+ expect(buildResolveTargetSelectorScript('download-review-images-input')).toContain('Download review images');
+ expect(buildResolveTargetSelectorScript('time-period-start-input')).toContain('Time Period');
+ expect(buildResolveTargetSelectorScript('confirm-export-button')).toContain('Download');
+ expect(TIME_PERIOD_START_INPUT_SELECTOR).toContain('time-period-start-input');
+ expect(TIME_PERIOD_START_MONTH_OFFSET).toBe(-3);
+ expect(TIME_PERIOD_START_DAY_OFFSET).toBe(7);
+ expect(buildReadInputValueScript(TIME_PERIOD_START_INPUT_SELECTOR)).toContain('time-period-start-input');
+ expect(buildDispatchEnterOnInputScript(TIME_PERIOD_START_INPUT_SELECTOR)).toContain("new KeyboardEvent('keydown'");
+ expect(buildDispatchEnterOnInputScript(TIME_PERIOD_START_INPUT_SELECTOR)).toContain("new KeyboardEvent('keypress'");
+ expect(buildDispatchEnterOnInputScript(TIME_PERIOD_START_INPUT_SELECTOR)).toContain("new KeyboardEvent('keyup'");
+ expect(buildWaitForExportReviewReadyScript(300000, 1000)).toContain('.putButton .common-btn.en_common-btn');
+ expect(buildWaitForExportReviewReadyScript(300000, 1000)).toContain('.shopdoraLoginPage');
+ expect(buildWaitForExportReviewReadyScript(300000, 1000)).toContain('Export Review');
+ });
+
+ it('computes the date from the input value using -3 months + 7 days', () => {
+ expect(computeShiftedDateFromInputValue('2026-04-14')).toBe('2026-01-21');
+ expect(computeShiftedDateFromInputValue('2026/05/31')).toBe('2026-03-07');
+ expect(() => computeShiftedDateFromInputValue('not-a-date')).toThrow(
+ 'Shopee product-shopdora-download could not parse the time-period start date',
+ );
+ });
+
+ it('clicks, computes from the current input value, types the result, and presses Enter', async () => {
+ const click = vi.fn>().mockResolvedValue(undefined);
+ const typeText = vi.fn>().mockResolvedValue(undefined);
+ const pressKey = vi.fn>().mockResolvedValue(undefined);
+ const nativeKeyPress = vi.fn>>().mockResolvedValue(undefined);
+ const wait = vi.fn>().mockResolvedValue(undefined);
+ const evaluate = vi.fn>().mockImplementation(async (script) => {
+ const source = String(script ?? '');
+ if (source.includes('const target = "time-period-start-input";')) {
+ return { ok: true, selector: TIME_PERIOD_START_INPUT_SELECTOR };
+ }
+ if (source.includes("new KeyboardEvent('keydown'")) {
+ return { ok: true };
+ }
+ return { ok: true, value: '2026-04-14' };
+ });
+ const page = { click, typeText, pressKey, nativeKeyPress, wait, evaluate } as unknown as IPage;
+
+ await expect(
+ setComputedTimePeriodStartValue(page),
+ ).resolves.toBe('2026-01-21');
+
+ expect(click).toHaveBeenCalledWith(TIME_PERIOD_START_INPUT_SELECTOR);
+ expect(evaluate).toHaveBeenNthCalledWith(1, expect.stringContaining('const target = "time-period-start-input";'));
+ expect(evaluate).toHaveBeenCalledWith(expect.stringContaining('time-period-start-input'));
+ expect(typeText).toHaveBeenCalledWith(TIME_PERIOD_START_INPUT_SELECTOR, '2026-01-21');
+ expect(evaluate).toHaveBeenCalledWith(expect.stringContaining("new KeyboardEvent('keydown'"));
+ expect(nativeKeyPress).toHaveBeenCalledWith('Enter');
+ expect(pressKey).not.toHaveBeenCalled();
+ expect(wait).toHaveBeenCalled();
+ });
+
+ 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).toHaveBeenCalledTimes(1);
+ expect(page.goto).toHaveBeenNthCalledWith(1, '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 typeText = vi.fn>().mockResolvedValue(undefined);
+ const pressKey = vi.fn>().mockResolvedValue(undefined);
+ const scroll = vi.fn>().mockResolvedValue(undefined);
+ const evaluate = vi.fn>().mockImplementation(async (script) => {
+ const source = String(script ?? '');
+ if (source.includes('.shopdoraLoginPage') && source.includes('.pageDetailLoginTitle') && !source.includes('.putButton .common-btn.en_common-btn')) {
+ return { hasShopdoraLoginPage: false, hasPageDetailLoginTitle: false };
+ }
+ if (source.includes('const target = "export-review-button";')) {
+ return { ok: true, selector: EXPORT_REVIEW_BUTTON_SELECTOR };
+ }
+ if (source.includes('const target = "time-period-start-input";')) {
+ return { ok: true, selector: TIME_PERIOD_START_INPUT_SELECTOR };
+ }
+ if (source.includes('const target = "download-review-images-label";')) {
+ return { ok: true, selector: '[data-opencli-shopee-product-shopdora-download-target="download-review-images-label"]' };
+ }
+ if (source.includes('const target = "download-review-images-input";')) {
+ return { ok: true, selector: DETAIL_FILTER_INPUT_SELECTOR };
+ }
+ if (source.includes('const target = "confirm-export-button";')) {
+ return { ok: true, selector: CONFIRM_EXPORT_BUTTON_SELECTOR };
+ }
+ if (source.includes("new KeyboardEvent('keydown'")) {
+ return { ok: true };
+ }
+ if (source.includes('value: input.value') && source.includes('time-period-start-input')) {
+ return { ok: true, value: '2026-04-14' };
+ }
+ if (source.includes('.putButton .common-btn.en_common-btn')) {
+ return { ok: true, text: 'Export Review' };
+ }
+ return { ok: true };
+ });
+ const waitForDownload = vi.fn>>()
+ .mockResolvedValue({ filename: downloadedFile });
+
+ const page = { goto, wait, click, typeText, pressKey, 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).toHaveBeenCalledTimes(1);
+ expect(goto).toHaveBeenNthCalledWith(
+ 1,
+ 'https://shopee.sg/Jeep-EW121-True-Wireless-Bluetooth-5.4-Earbuds-i.1058254930.25483790400',
+ { waitUntil: 'load' },
+ );
+ expect(wait).toHaveBeenCalledWith({ selector: '.putButton .common-btn.en_common-btn', timeout: 15 });
+ expect(click).toHaveBeenCalledWith(EXPORT_REVIEW_BUTTON_SELECTOR);
+ expect(click).toHaveBeenCalledWith(TIME_PERIOD_START_INPUT_SELECTOR);
+ expect(click).toHaveBeenCalledWith(CONFIRM_EXPORT_BUTTON_SELECTOR);
+ expect(typeText).toHaveBeenCalledWith(TIME_PERIOD_START_INPUT_SELECTOR, '2026-01-21');
+ expect(pressKey).toHaveBeenCalledWith('Enter');
+ expect(scroll).toHaveBeenCalled();
+ expect(evaluate).toHaveBeenCalledWith(expect.stringContaining('export-review-button'));
+ expect(wait).toHaveBeenCalledWith({ selector: EXPORT_DIALOG_SELECTOR, timeout: 10 });
+ expect(evaluate).toHaveBeenCalledWith(expect.stringContaining('download-review-images-input'));
+ expect(evaluate).toHaveBeenCalledWith(expect.stringContaining('confirm-export-button'));
+ expect(evaluate).toHaveBeenCalledWith(expect.stringContaining('.putButton .common-btn.en_common-btn'));
+ expect(waitForDownload).toHaveBeenCalledWith({
+ startedAfterMs: expect.any(Number),
+ timeoutMs: 600000,
+ });
+ 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',
+ shopdora_login_message: '',
+ }]);
+ });
+
+ it('skips the detail filter when it is unavailable and continues downloading', async () => {
+ const downloadedFile = '/tmp/opencli-shopee-product-shopdora-download-test/reviews-no-detail.csv';
+ const goto = vi.fn>().mockResolvedValue(undefined);
+ const wait = vi.fn>().mockResolvedValue(undefined);
+ const click = vi.fn>().mockResolvedValue(undefined);
+ const typeText = vi.fn>().mockResolvedValue(undefined);
+ const pressKey = vi.fn>().mockResolvedValue(undefined);
+ const scroll = vi.fn>().mockResolvedValue(undefined);
+ const evaluate = vi.fn>().mockImplementation(async (script) => {
+ const source = String(script ?? '');
+ if (source.includes('.shopdoraLoginPage') && source.includes('.pageDetailLoginTitle') && !source.includes('.putButton .common-btn.en_common-btn')) {
+ return { hasShopdoraLoginPage: false, hasPageDetailLoginTitle: false };
+ }
+ if (source.includes('const target = "export-review-button";')) {
+ return { ok: true, selector: EXPORT_REVIEW_BUTTON_SELECTOR };
+ }
+ if (source.includes('const target = "time-period-start-input";')) {
+ return { ok: true, selector: TIME_PERIOD_START_INPUT_SELECTOR };
+ }
+ if (source.includes('const target = "download-review-images-label";') || source.includes('const target = "download-review-images-input";')) {
+ return { ok: false, error: 'target_not_found' };
+ }
+ if (source.includes('const target = "confirm-export-button";')) {
+ return { ok: true, selector: CONFIRM_EXPORT_BUTTON_SELECTOR };
+ }
+ if (source.includes("new KeyboardEvent('keydown'")) {
+ return { ok: true };
+ }
+ if (source.includes('value: input.value') && source.includes('time-period-start-input')) {
+ return { ok: true, value: '2026-04-14' };
+ }
+ if (source.includes('.putButton .common-btn.en_common-btn')) {
+ return { ok: true, text: 'Export Review' };
+ }
+ return { ok: true };
+ });
+ const waitForDownload = vi.fn>>()
+ .mockResolvedValue({ filename: downloadedFile });
+
+ const page = { goto, wait, click, typeText, pressKey, 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(click).toHaveBeenCalledWith(CONFIRM_EXPORT_BUTTON_SELECTOR);
+ expect(waitForDownload).toHaveBeenCalledWith({
+ startedAfterMs: expect.any(Number),
+ timeoutMs: 600000,
+ });
+ expect(result).toEqual([{
+ status: 'success',
+ message: 'Downloaded Shopee product Shopdora export after skipping the unavailable 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',
+ shopdora_login_message: '',
+ }]);
+ });
+
+ it('appends the Shopdora login message when the soft login title is present on the page', async () => {
+ const downloadedFile = '/tmp/opencli-shopee-product-shopdora-download-test/reviews-soft-login.csv';
+ const goto = vi.fn>().mockResolvedValue(undefined);
+ const wait = vi.fn>().mockResolvedValue(undefined);
+ const click = vi.fn>().mockResolvedValue(undefined);
+ const typeText = vi.fn>().mockResolvedValue(undefined);
+ const pressKey = vi.fn>().mockResolvedValue(undefined);
+ const scroll = vi.fn>().mockResolvedValue(undefined);
+ const evaluate = vi.fn>().mockImplementation(async (script) => {
+ const source = String(script ?? '');
+ if (source.includes('.shopdoraLoginPage') && source.includes('.pageDetailLoginTitle') && !source.includes('.putButton .common-btn.en_common-btn')) {
+ return { hasShopdoraLoginPage: false, hasPageDetailLoginTitle: true };
+ }
+ if (source.includes('const target = "export-review-button";')) {
+ return { ok: true, selector: EXPORT_REVIEW_BUTTON_SELECTOR };
+ }
+ if (source.includes('const target = "time-period-start-input";')) {
+ return { ok: true, selector: TIME_PERIOD_START_INPUT_SELECTOR };
+ }
+ if (source.includes('const target = "download-review-images-label";')) {
+ return { ok: true, selector: '[data-opencli-shopee-product-shopdora-download-target="download-review-images-label"]' };
+ }
+ if (source.includes('const target = "download-review-images-input";')) {
+ return { ok: true, selector: DETAIL_FILTER_INPUT_SELECTOR };
+ }
+ if (source.includes('const target = "confirm-export-button";')) {
+ return { ok: true, selector: CONFIRM_EXPORT_BUTTON_SELECTOR };
+ }
+ if (source.includes("new KeyboardEvent('keydown'")) {
+ return { ok: true };
+ }
+ if (source.includes('value: input.value') && source.includes('time-period-start-input')) {
+ return { ok: true, value: '2026-04-14' };
+ }
+ if (source.includes('.putButton .common-btn.en_common-btn')) {
+ return { ok: true, text: 'Export Review' };
+ }
+ return { ok: true };
+ });
+ const waitForDownload = vi.fn>>()
+ .mockResolvedValue({ filename: downloadedFile });
+ const page = { goto, wait, click, typeText, pressKey, 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(result).toEqual([{
+ status: 'success',
+ message: 'Downloaded Shopee product Shopdora export with the recorded good-detail filter. Shopdora 未登录。',
+ local_url: pathToFileURL(downloadedFile).href,
+ local_path: downloadedFile,
+ product_url: 'https://shopee.sg/Jeep-EW121-True-Wireless-Bluetooth-5.4-Earbuds-i.1058254930.25483790400',
+ shopdora_login_message: 'Shopdora 未登录',
+ }]);
+ });
+
+ it('returns the login info immediately when export opens the Shopdora login page', async () => {
+ const goto = vi.fn>().mockResolvedValue(undefined);
+ const wait = vi.fn>().mockResolvedValue(undefined);
+ const click = vi.fn>().mockResolvedValue(undefined);
+ const typeText = vi.fn>().mockResolvedValue(undefined);
+ const pressKey = vi.fn>().mockResolvedValue(undefined);
+ const scroll = vi.fn>().mockResolvedValue(undefined);
+ let loginCheckCount = 0;
+ const evaluate = vi.fn>().mockImplementation(async (script) => {
+ const source = String(script ?? '');
+ if (source.includes('.shopdoraLoginPage') && source.includes('.pageDetailLoginTitle') && !source.includes('.putButton .common-btn.en_common-btn')) {
+ loginCheckCount += 1;
+ return loginCheckCount === 1
+ ? { hasShopdoraLoginPage: false, hasPageDetailLoginTitle: false }
+ : { hasShopdoraLoginPage: true, hasPageDetailLoginTitle: false };
+ }
+ if (source.includes('const target = "export-review-button";')) {
+ return { ok: true, selector: EXPORT_REVIEW_BUTTON_SELECTOR };
+ }
+ return { ok: true };
+ });
+ const waitForDownload = vi.fn>>()
+ .mockResolvedValue({ filename: '/tmp/should-not-download.csv' });
+ const page = { goto, wait, click, typeText, pressKey, scroll, evaluate, waitForDownload } as unknown as IPage;
+
+ await expect(command!.func!(page, {
+ url: 'https://shopee.sg/Jeep-EW121-True-Wireless-Bluetooth-5.4-Earbuds-i.1058254930.25483790400',
+ })).resolves.toEqual([{
+ status: 'not_logged_in',
+ message: 'Shopdora 未登录,请先登录 Shopdora 后重试。',
+ local_url: '',
+ local_path: '',
+ product_url: 'https://shopee.sg/Jeep-EW121-True-Wireless-Bluetooth-5.4-Earbuds-i.1058254930.25483790400',
+ shopdora_login_message: 'Shopdora 未登录',
+ }]);
+
+ expect(wait).not.toHaveBeenCalledWith({ selector: EXPORT_DIALOG_SELECTOR, timeout: 10 });
+ expect(typeText).not.toHaveBeenCalled();
+ expect(waitForDownload).not.toHaveBeenCalled();
+ });
+
+ 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).toHaveBeenCalledTimes(1);
+ expect(page.goto).toHaveBeenNthCalledWith(1, '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..90fab6c0c
--- /dev/null
+++ b/clis/shopee/product-shopdora-download.ts
@@ -0,0 +1,611 @@
+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 {
+ appendShopdoraLoginMessage,
+ readShopdoraLoginState,
+ SHOPDORA_NOT_LOGGED_IN_MESSAGE,
+ simulateHumanBehavior,
+ waitRandomDuration,
+} from './shared.js';
+
+const RESOLVED_TARGET_ATTRIBUTE = 'data-opencli-shopee-product-shopdora-download-target';
+const EXPORT_DIALOG_SELECTOR = '.t-dialog__body .review';
+const EXPORT_REVIEW_BUTTON_TEXT = 'Export Review';
+const REVIEW_IMAGES_CHECKBOX_LABEL_TEXT = 'Download review images';
+const TIME_PERIOD_TITLE_TEXT = 'Time Period';
+const CONFIRM_EXPORT_BUTTON_TEXT = 'Download';
+
+const EXPORT_REVIEW_BUTTON_SELECTOR =
+ `[${RESOLVED_TARGET_ATTRIBUTE}="export-review-button"]`;
+const DETAIL_FILTER_LABEL_SELECTOR =
+ `[${RESOLVED_TARGET_ATTRIBUTE}="download-review-images-label"]`;
+const DETAIL_FILTER_INPUT_SELECTOR =
+ `[${RESOLVED_TARGET_ATTRIBUTE}="download-review-images-input"]`;
+const TIME_PERIOD_START_INPUT_SELECTOR =
+ `[${RESOLVED_TARGET_ATTRIBUTE}="time-period-start-input"]`;
+const TIME_PERIOD_START_MONTH_OFFSET = -3;
+const TIME_PERIOD_START_DAY_OFFSET = 7;
+const CONFIRM_EXPORT_BUTTON_SELECTOR =
+ `[${RESOLVED_TARGET_ATTRIBUTE}="confirm-export-button"]`;
+
+const SHOPEE_WORKSPACE = 'site:shopee';
+const EXPORT_DOWNLOAD_TIMEOUT_SECONDS = 600;
+const EXPORT_DOWNLOAD_TIMEOUT_MS = EXPORT_DOWNLOAD_TIMEOUT_SECONDS * 1000;
+
+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,
+ };
+ })()
+ `;
+}
+
+function buildResolveTargetSelectorScript(target: 'export-review-button' | 'download-review-images-label' | 'download-review-images-input' | 'time-period-start-input' | 'confirm-export-button'): string {
+ return `
+ (() => {
+ const target = ${JSON.stringify(target)};
+ const attr = ${JSON.stringify(RESOLVED_TARGET_ATTRIBUTE)};
+ const normalizeText = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
+ const mark = (name, element) => {
+ if (!(element instanceof HTMLElement)) return { ok: false, error: 'target_not_found' };
+ element.setAttribute(attr, name);
+ return { ok: true, selector: '[' + attr + '="' + name + '"]' };
+ };
+ const findCheckboxLabel = (labelText) => {
+ const root = findDialogRoot() || document;
+ const wanted = normalizeText(labelText);
+ const labels = Array.from(root.querySelectorAll('label.t-checkbox'));
+ return labels.find((label) => {
+ const text = normalizeText(label.querySelector('.t-checkbox__label')?.textContent || label.textContent || '');
+ return text === wanted;
+ }) || null;
+ };
+ const findDialogRoot = () => {
+ const roots = Array.from(document.querySelectorAll(${JSON.stringify(EXPORT_DIALOG_SELECTOR)}));
+ if (roots.length === 1) return roots[0];
+ return roots.find((root) => normalizeText(root.textContent).includes(${JSON.stringify(TIME_PERIOD_TITLE_TEXT)})) || null;
+ };
+ const findReviewBlockByTitle = (titleText) => {
+ const root = findDialogRoot();
+ if (!(root instanceof HTMLElement)) return null;
+ const wanted = normalizeText(titleText);
+ const rows = Array.from(root.querySelectorAll('.reviewText'));
+ return rows.find((row) => {
+ const title = normalizeText(row.querySelector('.reviewTitle')?.textContent || '');
+ return title.startsWith(wanted);
+ }) || null;
+ };
+ const findButtonByText = (scope, text) => {
+ if (!(scope instanceof HTMLElement) && scope !== document) return null;
+ const wanted = normalizeText(text);
+ const buttons = Array.from(scope.querySelectorAll('button, .common-btn.en_common-btn, [role="button"]'));
+ return buttons.find((element) => normalizeText(element.textContent).includes(wanted)) || null;
+ };
+
+ if (target === 'export-review-button') {
+ const button = findButtonByText(document, ${JSON.stringify(EXPORT_REVIEW_BUTTON_TEXT)});
+ return mark(target, button);
+ }
+
+ if (target === 'download-review-images-label') {
+ const label = findCheckboxLabel(${JSON.stringify(REVIEW_IMAGES_CHECKBOX_LABEL_TEXT)});
+ return mark(target, label);
+ }
+
+ if (target === 'download-review-images-input') {
+ const label = findCheckboxLabel(${JSON.stringify(REVIEW_IMAGES_CHECKBOX_LABEL_TEXT)});
+ const input = label?.querySelector('input.t-checkbox__former') || null;
+ return mark(target, input);
+ }
+
+ if (target === 'time-period-start-input') {
+ const row = findReviewBlockByTitle(${JSON.stringify(TIME_PERIOD_TITLE_TEXT)});
+ const input = row?.querySelector('.t-range-input__inner-left input.t-input__inner') || null;
+ return mark(target, input);
+ }
+
+ if (target === 'confirm-export-button') {
+ const root = findDialogRoot();
+ const button = findButtonByText(root || document, ${JSON.stringify(CONFIRM_EXPORT_BUTTON_TEXT)});
+ return mark(target, button);
+ }
+
+ return { ok: false, error: 'unknown_target' };
+ })()
+ `;
+}
+
+async function resolveTargetSelector(
+ page: IPage,
+ target: 'export-review-button' | 'download-review-images-label' | 'download-review-images-input' | 'time-period-start-input' | 'confirm-export-button',
+ label: string,
+): Promise {
+ const result = await page.evaluate(buildResolveTargetSelectorScript(target));
+ if (
+ !result
+ || typeof result !== 'object'
+ || !(result as { ok?: boolean; selector?: string }).ok
+ || typeof (result as { selector?: string }).selector !== 'string'
+ ) {
+ throw new CommandExecutionError(`Shopee product-shopdora-download could not resolve ${label}`);
+ }
+
+ return (result as { selector: string }).selector;
+}
+
+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 loginSelector = '.shopdoraLoginPage';
+ 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 = () => {
+ if (document.querySelector(loginSelector)) {
+ resolve({ ok: false, reason: 'shopdora_login_required' });
+ return;
+ }
+
+ 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 = EXPORT_DOWNLOAD_TIMEOUT_MS,
+ pollIntervalMs = 1000,
+): Promise {
+ const result = await page.evaluate(buildWaitForExportReviewReadyScript(timeoutMs, pollIntervalMs));
+ if (
+ result
+ && typeof result === 'object'
+ && (result as { ok?: boolean; reason?: string }).ok === false
+ && (result as { reason?: string }).reason === 'shopdora_login_required'
+ ) {
+ throw new CommandExecutionError(
+ 'Shopee product-shopdora-download requires Shopdora login',
+ `${SHOPDORA_NOT_LOGGED_IN_MESSAGE},请先登录 Shopdora 后重试。`,
+ );
+ }
+}
+
+function buildReadInputValueScript(selector: string): string {
+ return `
+ (() => {
+ const input = document.querySelector(${JSON.stringify(selector)});
+ if (!(input instanceof HTMLInputElement)) {
+ return { ok: false, error: 'date_input_not_found' };
+ }
+
+ return { ok: true, value: input.value };
+ })()
+ `;
+}
+
+function buildDispatchEnterOnInputScript(selector: string): string {
+ return `
+ (() => {
+ const input = document.querySelector(${JSON.stringify(selector)});
+ if (!(input instanceof HTMLInputElement)) {
+ return { ok: false, error: 'date_input_not_found' };
+ }
+
+ input.focus();
+ const eventInit = {
+ key: 'Enter',
+ code: 'Enter',
+ keyCode: 13,
+ which: 13,
+ bubbles: true,
+ cancelable: true,
+ };
+ input.dispatchEvent(new KeyboardEvent('keydown', eventInit));
+ input.dispatchEvent(new KeyboardEvent('keypress', eventInit));
+ input.dispatchEvent(new KeyboardEvent('keyup', eventInit));
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ return { ok: true };
+ })()
+ `;
+}
+
+function computeShiftedDateFromInputValue(
+ value: string,
+ monthOffset = TIME_PERIOD_START_MONTH_OFFSET,
+ dayOffset = TIME_PERIOD_START_DAY_OFFSET,
+): string {
+ const normalized = String(value ?? '').trim();
+ const match = normalized.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})(?:\D.*)?$/);
+ if (!match) {
+ throw new CommandExecutionError(
+ 'Shopee product-shopdora-download could not parse the time-period start date',
+ `Unsupported input value: ${normalized || '(empty)'}`,
+ );
+ }
+
+ const year = Number.parseInt(match[1], 10);
+ const monthIndex = Number.parseInt(match[2], 10) - 1;
+ const day = Number.parseInt(match[3], 10);
+ const target = new Date(Date.UTC(year, monthIndex, day));
+ if (
+ Number.isNaN(target.getTime())
+ || target.getUTCFullYear() !== year
+ || target.getUTCMonth() !== monthIndex
+ || target.getUTCDate() !== day
+ ) {
+ throw new CommandExecutionError(
+ 'Shopee product-shopdora-download could not parse the time-period start date',
+ `Invalid input value: ${normalized}`,
+ );
+ }
+
+ const originalDay = target.getUTCDate();
+ target.setUTCDate(1);
+ target.setUTCMonth(target.getUTCMonth() + monthOffset);
+ const daysInMonth = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
+ target.setUTCDate(Math.min(originalDay, daysInMonth));
+ target.setUTCDate(target.getUTCDate() + dayOffset);
+
+ const yyyy = target.getUTCFullYear();
+ const mm = String(target.getUTCMonth() + 1).padStart(2, '0');
+ const dd = String(target.getUTCDate()).padStart(2, '0');
+ return `${yyyy}-${mm}-${dd}`;
+}
+
+async function setComputedTimePeriodStartValue(page: IPage): Promise {
+ const inputSelector = await resolveTargetSelector(page, 'time-period-start-input', 'time-period start input');
+ await clickSelector(page, inputSelector, 'time-period start input');
+ await waitRandomDuration(page, [300, 900]);
+
+ const inputState = await page.evaluate(buildReadInputValueScript(inputSelector));
+ if (!inputState || typeof inputState !== 'object' || !(inputState as { ok?: boolean }).ok) {
+ throw new CommandExecutionError('Shopee product-shopdora-download could not read the time-period start date');
+ }
+
+ const nextValue = computeShiftedDateFromInputValue(String((inputState as { value?: unknown }).value ?? ''));
+
+ try {
+ await page.typeText(inputSelector, nextValue);
+ } catch (error) {
+ throw new CommandExecutionError(
+ 'Shopee product-shopdora-download could not set the time-period start date',
+ getErrorMessage(error),
+ );
+ }
+
+ await waitRandomDuration(page, [200, 700]);
+
+ const enterDispatchResult = await page.evaluate(buildDispatchEnterOnInputScript(inputSelector));
+ if (!enterDispatchResult || typeof enterDispatchResult !== 'object' || !(enterDispatchResult as { ok?: boolean }).ok) {
+ throw new CommandExecutionError('Shopee product-shopdora-download could not trigger Enter on the time-period start date');
+ }
+
+ try {
+ if (typeof page.nativeKeyPress === 'function') {
+ await page.nativeKeyPress('Enter');
+ } else {
+ await page.pressKey('Enter');
+ }
+ } catch (error) {
+ throw new CommandExecutionError(
+ 'Shopee product-shopdora-download could not submit the time-period start date',
+ getErrorMessage(error),
+ );
+ }
+
+ return nextValue;
+}
+
+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,
+ checked: boolean,
+ label: string,
+ opts: { allowMissing?: boolean } = {},
+): Promise {
+ let labelSelector: string;
+ let inputSelector: string;
+ try {
+ labelSelector = await resolveTargetSelector(page, 'download-review-images-label', `${label} label`);
+ inputSelector = await resolveTargetSelector(page, 'download-review-images-input', label);
+ } catch (error) {
+ if (opts.allowMissing) {
+ return false;
+ }
+ throw error;
+ }
+ await simulateHumanBehavior(page, {
+ selector: labelSelector,
+ scrollRangePx: [30, 120],
+ preWaitRangeMs: [250, 700],
+ postWaitRangeMs: [150, 450],
+ });
+ await clickSelector(page, labelSelector, `${label} label`);
+ await waitRandomDuration(page, [1500, 3500]);
+ await ensureCheckboxState(page, inputSelector, checked, label);
+ await waitRandomDuration(page, [2000, 5000]);
+ return true;
+}
+
+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,
+ timeoutSeconds: EXPORT_DOWNLOAD_TIMEOUT_SECONDS,
+ 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', 'shopdora_login_message'],
+ 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);
+ const initialShopdoraLoginState = await readShopdoraLoginState(page);
+ await page.wait({ selector: '.putButton .common-btn.en_common-btn', timeout: 15 });
+ const exportReviewButtonSelector = await resolveTargetSelector(page, 'export-review-button', 'Export Review button');
+ await simulateHumanBehavior(page, {
+ selector: exportReviewButtonSelector,
+ scrollRangePx: [60, 180],
+ preWaitRangeMs: [500, 1200],
+ postWaitRangeMs: [300, 800],
+ allowReverseScroll: false,
+ });
+ await waitRandomDuration(page, [3000, 5000]);
+ await clickSelector(page, exportReviewButtonSelector, 'Export Review');
+ await waitRandomDuration(page, [2000, 6000]);
+
+ const postExportShopdoraLoginState = await readShopdoraLoginState(page);
+ if (postExportShopdoraLoginState.hasShopdoraLoginPage) {
+ return [{
+ status: 'not_logged_in',
+ message: `${SHOPDORA_NOT_LOGGED_IN_MESSAGE},请先登录 Shopdora 后重试。`,
+ local_url: '',
+ local_path: '',
+ product_url: productUrl,
+ shopdora_login_message: postExportShopdoraLoginState.loginMessage,
+ }];
+ }
+ const shopdoraLoginMessage =
+ postExportShopdoraLoginState.loginMessage || initialShopdoraLoginState.loginMessage;
+
+ await page.wait({ selector: EXPORT_DIALOG_SELECTOR, timeout: 10 });
+ const timePeriodStartInputSelector = await resolveTargetSelector(page, 'time-period-start-input', 'time-period start input');
+ await simulateHumanBehavior(page, {
+ selector: timePeriodStartInputSelector,
+ scrollRangePx: [20, 80],
+ preWaitRangeMs: [250, 600],
+ postWaitRangeMs: [150, 400],
+ });
+ await setComputedTimePeriodStartValue(page);
+ await waitRandomDuration(page, [1000, 2500]);
+
+ const appliedDetailFilter = await applyCheckboxStep(
+ page,
+ true,
+ 'detail filter',
+ { allowMissing: true },
+ );
+
+ const confirmExportButtonSelector = await resolveTargetSelector(page, 'confirm-export-button', 'export confirm button');
+ await simulateHumanBehavior(page, {
+ selector: confirmExportButtonSelector,
+ scrollRangePx: [20, 100],
+ preWaitRangeMs: [250, 700],
+ postWaitRangeMs: [200, 500],
+ });
+ const downloadStartedAtMs = Date.now();
+ await clickSelector(page, confirmExportButtonSelector, 'export confirm button');
+ await waitForExportReviewReady(page);
+
+ const download = await page.waitForDownload({
+ startedAfterMs: downloadStartedAtMs,
+ timeoutMs: EXPORT_DOWNLOAD_TIMEOUT_MS,
+ });
+ const localPath = String(download?.filename ?? '').trim();
+ if (!localPath) {
+ throw new CommandExecutionError('Shopee product-shopdora-download finished without a local filename');
+ }
+
+ return [{
+ status: 'success',
+ message: appendShopdoraLoginMessage(
+ appliedDetailFilter
+ ? 'Downloaded Shopee product Shopdora export with the recorded good-detail filter.'
+ : 'Downloaded Shopee product Shopdora export after skipping the unavailable detail filter.',
+ shopdoraLoginMessage,
+ ),
+ local_url: pathToFileURL(localPath).href,
+ local_path: localPath,
+ product_url: productUrl,
+ shopdora_login_message: shopdoraLoginMessage,
+ }];
+ },
+});
+
+export const __test__ = {
+ EXPORT_DIALOG_SELECTOR,
+ EXPORT_REVIEW_BUTTON_SELECTOR,
+ DETAIL_FILTER_LABEL_SELECTOR,
+ DETAIL_FILTER_INPUT_SELECTOR,
+ TIME_PERIOD_START_INPUT_SELECTOR,
+ TIME_PERIOD_START_MONTH_OFFSET,
+ TIME_PERIOD_START_DAY_OFFSET,
+ CONFIRM_EXPORT_BUTTON_SELECTOR,
+ normalizeShopeeReviewUrl,
+ bindShopeeProductTab,
+ ensureShopeeProductPage,
+ buildEnsureCheckboxStateScript,
+ buildResolveTargetSelectorScript,
+ buildReadInputValueScript,
+ buildDispatchEnterOnInputScript,
+ computeShiftedDateFromInputValue,
+ buildWaitForExportReviewReadyScript,
+ setComputedTimePeriodStartValue,
+};
diff --git a/clis/shopee/product.test.ts b/clis/shopee/product.test.ts
new file mode 100644
index 000000000..8985e938a
--- /dev/null
+++ b/clis/shopee/product.test.ts
@@ -0,0 +1,240 @@
+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',
+ 'shopdora_login_message',
+ 'title',
+ 'rating_score',
+ 'shopdora_price_range',
+ 'shopee_current_price',
+ 'main_image_url',
+ 'video_urls',
+ 'thumbnail_urls',
+ 'image_variant_options',
+ 'text_variant_options',
+ 'detail_seller_name',
+ 'shop_display_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 titleField = PRODUCT_FIELDS.find((field) => field.name === 'title');
+ const videoField = PRODUCT_FIELDS.find((field) => field.name === 'video_urls');
+ const thumbnailField = PRODUCT_FIELDS.find((field) => field.name === 'thumbnail_urls');
+ const attrOptionsField = PRODUCT_FIELDS.find((field) => field.name === 'image_variant_options');
+ const specOptionsField = PRODUCT_FIELDS.find((field) => field.name === 'text_variant_options');
+ const sales30dField = PRODUCT_FIELDS.find((field) => field.name === 'sales_30d');
+ const totalGmvField = PRODUCT_FIELDS.find((field) => field.name === 'total_gmv');
+
+ expect(titleField).toMatchObject({ transform: 'remove_buttons' });
+
+ expect(videoField).toMatchObject({
+ type: 'list',
+ fields: [
+ { name: 'video_urls', type: 'attribute', attribute: 'src', transform: 'absolute_url' },
+ ],
+ });
+ expect(thumbnailField).toMatchObject({
+ type: 'list',
+ fields: [{ name: 'thumbnail_urls', type: 'attribute', attribute: 'src', transform: 'image_src' }],
+ });
+ expect(attrOptionsField).toMatchObject({
+ type: 'list',
+ fields: expect.arrayContaining([
+ expect.objectContaining({ name: 'option_name', type: 'text' }),
+ expect.objectContaining({ name: 'option_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: 'option_name', type: 'text' }),
+ expect.objectContaining({ name: 'is_selected', transform: 'selected_class' }),
+ ]),
+ });
+ expect(sales30dField).toMatchObject({
+ type: 'labeled_text',
+ lookupLabel: '30-Day Sales',
+ valueSelector: '.item-main',
+ });
+ expect(totalGmvField).toMatchObject({
+ type: 'labeled_text',
+ lookupLabel: 'GMV',
+ valueSelector: '.item-main',
+ });
+ });
+});
+
+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', detail_seller_name: '', stock: '' },
+ { title: 'Product B', detail_seller_name: 'Shop 1', stock: '99' },
+ ),
+ ).toEqual({
+ title: 'Product A',
+ detail_seller_name: 'Shop 1',
+ stock: '99',
+ });
+ });
+});
+
+describe('hasMeaningfulProductData', () => {
+ it('returns false for empty extraction rows', () => {
+ expect(hasMeaningfulProductData({ title: '', detail_seller_name: '' })).toBe(false);
+ });
+
+ it('returns true once any mapped product field has content', () => {
+ expect(hasMeaningfulProductData({ title: 'Wireless Earbuds' })).toBe(true);
+ });
+});
+
+describe('product command Shopdora login annotations', () => {
+ it('returns product data and includes a Shopdora login message when the soft login title is present', async () => {
+ const command = getRegistry().get('shopee/product');
+ const url = 'https://shopee.sg/Jeep-EW121-True-Wireless-Bluetooth-5.4-Earbuds-i.1058254930.25483790400';
+ const goto = vi.fn(async () => {});
+ const wait = vi.fn(async () => {});
+ const scroll = vi.fn(async () => {});
+ const evaluate = vi.fn(async (script: string) => {
+ if (script.includes('.shopdoraLoginPage') && script.includes('.pageDetailLoginTitle')) {
+ return { hasShopdoraLoginPage: false, hasPageDetailLoginTitle: true };
+ }
+ if (script.includes('const fields =') && script.includes('"title"')) {
+ return { title: 'Wireless Earbuds' };
+ }
+ return { ok: true };
+ });
+ const page = { goto, wait, scroll, evaluate } as unknown as import('@jackwener/opencli/types').IPage;
+
+ await expect(command!.func!(page, { url })).resolves.toEqual([{
+ product_url: url,
+ title: 'Wireless Earbuds',
+ shopdora_login_message: 'Shopdora 未登录',
+ }]);
+ });
+});
+
+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 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).toHaveBeenCalledTimes(1);
+ expect(page.goto).toHaveBeenNthCalledWith(1, 'https://shopee.sg/product-i.1.2', { waitUntil: 'load' });
+ });
+
+ it('falls back to 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).toHaveBeenCalledTimes(1);
+ expect(page.goto).toHaveBeenNthCalledWith(1, '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..5a6f48528
--- /dev/null
+++ b/clis/shopee/product.ts
@@ -0,0 +1,598 @@
+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 { readShopdoraLoginState, simulateHumanBehavior } from './shared.js';
+
+type ShopeeField = {
+ name: string;
+ selector: string;
+ type?: 'text' | 'attribute' | 'list' | 'labeled_text';
+ attribute?: string;
+ fields?: ShopeeField[];
+ transform?: 'absolute_url' | 'selected_class' | 'image_src' | 'remove_buttons';
+ lookupLabel?: string;
+ valueSelector?: string;
+};
+
+type ShopeeImageAttributeKey =
+ | 'src'
+ | 'data-src'
+ | 'data-lazy-src'
+ | 'data-original'
+ | 'data-img-src'
+ | 'srcset'
+ | 'data-srcset'
+ | 'data-lazy-srcset';
+
+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 {
+ const directImageAttributes = [
+ 'src',
+ 'data-src',
+ 'data-lazy-src',
+ 'data-original',
+ 'data-img-src',
+ ] as const;
+ const srcsetImageAttributes = [
+ 'srcset',
+ 'data-srcset',
+ 'data-lazy-srcset',
+ ] as const;
+
+ for (const key of directImageAttributes) {
+ const value = String(attributes[key] ?? '').trim();
+ if (value) return value;
+ }
+
+ for (const key of srcsetImageAttributes) {
+ const value = firstUrlFromSrcset(attributes[key]);
+ if (value) return value;
+ }
+
+ return '';
+}
+
+const PRODUCT_FIELDS: ShopeeField[] = [
+ { name: 'title', selector: 'h1.vR6K3w > span, h1.vR6K3w', transform: 'remove_buttons' },
+ { name: 'rating_score', selector: 'div.F9RHbS.dQEiAI' },
+ { name: 'rating_count', selector: 'button.flex.e2p50f:nth-of-type(2) > .F9RHbS' },
+ { name: 'sold_count', selector: '.aleSBU > .AcmPRb' },
+ { name: 'shopdora_price_range', selector: '.shopdoraPirceList span' },
+ { name: 'shopee_current_price', selector: '.jRlVo0 .IZPeQz.B67UQ0' },
+ { name: 'shopee_original_price', selector: '.ZA5sW5' },
+ { name: 'shopee_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_urls',
+ 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_urls',
+ selector: '',
+ type: 'attribute',
+ attribute: 'src',
+ transform: 'absolute_url',
+ },
+ ],
+ },
+ {
+ name: 'thumbnail_urls',
+ selector: '.airUhU .UBG7wZ .YM40Nc picture img.raRnQV, .airUhU .UBG7wZ .YM40Nc picture img',
+ type: 'list',
+ fields: [
+ {
+ name: 'thumbnail_urls',
+ selector: '',
+ type: 'attribute',
+ attribute: 'src',
+ transform: 'image_src',
+ },
+ ],
+ },
+ { name: 'first_variant_option_name', selector: '.j7HL5Q button:first-of-type span.ZivAAW' },
+ {
+ name: 'first_variant_option_image_url',
+ selector: '.j7HL5Q button:first-of-type picture, .j7HL5Q button:first-of-type img',
+ type: 'attribute',
+ attribute: 'src',
+ transform: 'image_src',
+ },
+ {
+ name: 'image_variant_options',
+ selector: '.j7HL5Q button:has(img)',
+ type: 'list',
+ fields: [
+ { name: 'option_name', selector: '.ZivAAW', type: 'text' },
+ { name: 'option_aria_label', selector: '', type: 'attribute', attribute: 'aria-label' },
+ { name: 'option_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: 'text_variant_options',
+ selector: '.j7HL5Q button:not(:has(img))',
+ type: 'list',
+ fields: [
+ { name: 'option_name', selector: '.ZivAAW', type: 'text' },
+ { name: 'option_aria_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_display_price', selector: '.t-table__body tr:first-child td:nth-child(2) p' },
+ {
+ name: 'shopee_item_id',
+ selector: '.detail-info',
+ type: 'labeled_text',
+ lookupLabel: 'Product ID',
+ valueSelector: '.item-main',
+ },
+ {
+ name: 'detail_seller_name',
+ selector: '.detail-info',
+ type: 'labeled_text',
+ lookupLabel: 'Seller',
+ valueSelector: '.item-main',
+ },
+ {
+ name: 'detail_seller_source',
+ selector: '.detail-info',
+ type: 'labeled_text',
+ lookupLabel: 'Seller',
+ valueSelector: '.sellerSourceTips',
+ },
+ {
+ name: 'brand_name',
+ selector: '.detail-info',
+ type: 'labeled_text',
+ lookupLabel: 'Brand',
+ valueSelector: '.item-main',
+ },
+ {
+ name: 'category_name',
+ selector: '.detail-info',
+ type: 'labeled_text',
+ lookupLabel: 'Category',
+ valueSelector: '.item-main',
+ },
+ {
+ name: 'category_sales_rank',
+ selector: '.detail-info',
+ type: 'labeled_text',
+ lookupLabel: 'Category',
+ valueSelector: '.tem-main',
+ },
+ {
+ name: 'listing_date',
+ selector: '.detail-info',
+ type: 'labeled_text',
+ lookupLabel: 'Listing Date',
+ valueSelector: '.item-main',
+ },
+ {
+ name: 'sales_1d_7d',
+ selector: '.detail-info',
+ type: 'labeled_text',
+ lookupLabel: 'Last 1d/7d Sales',
+ valueSelector: '.item-main',
+ },
+ {
+ name: 'sales_growth_30d',
+ selector: '.detail-info',
+ type: 'labeled_text',
+ lookupLabel: '30-Day Sales Growth',
+ valueSelector: '.item-main',
+ },
+ {
+ name: 'sales_30d',
+ selector: '.detail-info',
+ type: 'labeled_text',
+ lookupLabel: '30-Day Sales',
+ valueSelector: '.item-main',
+ },
+ {
+ name: 'gmv_30d',
+ selector: '.detail-info',
+ type: 'labeled_text',
+ lookupLabel: '30-Day GMV',
+ valueSelector: '.item-main',
+ },
+ {
+ name: 'total_sales',
+ selector: '.detail-info',
+ type: 'labeled_text',
+ lookupLabel: 'Total Sales',
+ valueSelector: '.item-main',
+ },
+ {
+ name: 'total_gmv',
+ selector: '.detail-info',
+ type: 'labeled_text',
+ lookupLabel: 'GMV',
+ valueSelector: '.item-main',
+ },
+ {
+ name: 'stock',
+ selector: '.detail-info',
+ type: 'labeled_text',
+ lookupLabel: 'Stock',
+ valueSelector: '.item-main',
+ },
+ { name: 'shop_display_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_duration', selector: '#sll2-pdp-product-shop .NGzCXN > :nth-child(3) .Cs6w3G' },
+ { name: 'shop_listing_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_chat_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',
+ 'shopdora_login_message',
+ ...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);
+ // Temporarily skip localStorage clearing while debugging the Shopee flow.
+ // 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 normalizeLabel = (value) => normalizeText(value).replace(/[::]/g, '');
+ 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';
+ }
+ if (field.transform === 'remove_buttons' && value instanceof Element) {
+ const clone = value.cloneNode(true);
+ if (clone instanceof Element) {
+ clone.querySelectorAll('button').forEach((node) => node.remove());
+ return normalizeText(clone.textContent || '');
+ }
+ }
+ 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 pickTargets = (scope, selector) => {
+ if (!selector) return [scope];
+ try {
+ const targets = Array.from(scope.querySelectorAll(selector));
+ return targets.length ? targets : [];
+ } catch {
+ return [];
+ }
+ };
+ const extractFieldValue = (scope, field) => {
+ const selector = typeof field.selector === 'string' ? field.selector.trim() : '';
+
+ if (field.type === 'labeled_text') {
+ const root = selector ? scope.querySelector(selector) : scope;
+ if (!(root instanceof Element || root instanceof Document)) return '';
+
+ const label = normalizeLabel(field.lookupLabel || '');
+ if (!label) return '';
+
+ const candidates = Array.from(root.querySelectorAll('.detail-info-item'));
+ for (const item of candidates) {
+ const titleNode = item.querySelector('.detail-info-item-title');
+ if (!titleNode) continue;
+ if (normalizeLabel(titleNode.textContent || '') !== label) continue;
+
+ const valueTarget = field.valueSelector
+ ? item.querySelector(field.valueSelector)
+ : item.querySelector('.detail-info-item-main');
+ const value = normalizeText(valueTarget?.textContent || '');
+ if (value) return value;
+ }
+
+ return '';
+ }
+
+ 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);
+ }
+
+ if (field.type === 'attribute') {
+ const targets = pickTargets(scope, selector);
+ for (const target of targets) {
+ if (!(target instanceof Element)) continue;
+ if (field.transform === 'image_src') {
+ const value = extractImageUrl(target);
+ if (value) return value;
+ continue;
+ }
+ const attrName = typeof field.attribute === 'string' && field.attribute.trim()
+ ? field.attribute.trim()
+ : target instanceof HTMLAnchorElement
+ ? 'href'
+ : 'src';
+ const value = toScalar(applyTransform(target.getAttribute(attrName) || '', field));
+ if (value) return value;
+ }
+ return '';
+ }
+
+ const targets = pickTargets(scope, selector);
+ for (const target of targets) {
+ if (!(target instanceof Element || target instanceof Document)) continue;
+ const rawValue = field.transform === 'remove_buttons'
+ ? applyTransform(target, field)
+ : applyTransform(target.textContent || '', field);
+ const value = normalizeText(rawValue);
+ if (value) return value;
+ }
+
+ return '';
+ };
+ 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 shopdoraLoginState = await readShopdoraLoginState(page);
+ const row = await extractProductDetails(page, productUrl);
+ row.shopdora_login_message = shopdoraLoginState.loginMessage;
+
+ 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..b7ae8dc24
--- /dev/null
+++ b/clis/shopee/shared.test.ts
@@ -0,0 +1,102 @@
+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('builds a Shopdora login-state reader for both hard and soft login markers', () => {
+ const script = __test__.buildReadShopdoraLoginStateScript();
+ expect(script).toContain('.shopdoraLoginPage');
+ expect(script).toContain('.pageDetailLoginTitle');
+ });
+
+ 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.4);
+ expect(wait).toHaveBeenCalledWith({ time: 0.4 });
+ });
+
+ 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, { time: 0.4 });
+ expect(page.wait).toHaveBeenNthCalledWith(2, { time: 0.2 });
+ 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'));
+ });
+
+ it('reads Shopdora login markers and maps them to a user-facing message', async () => {
+ const evaluate = vi.fn>().mockResolvedValue({
+ hasShopdoraLoginPage: false,
+ hasPageDetailLoginTitle: true,
+ });
+ const page = { evaluate } as unknown as IPage;
+
+ await expect(__test__.readShopdoraLoginState(page)).resolves.toEqual({
+ hasShopdoraLoginPage: false,
+ hasPageDetailLoginTitle: true,
+ loginMessage: 'Shopdora 未登录',
+ });
+ });
+
+ it('appends the Shopdora login message when present', () => {
+ expect(__test__.appendShopdoraLoginMessage('Downloaded successfully.', 'Shopdora 未登录'))
+ .toBe('Downloaded successfully. Shopdora 未登录。');
+ expect(__test__.appendShopdoraLoginMessage('Downloaded successfully.', ''))
+ .toBe('Downloaded successfully.');
+ });
+});
diff --git a/clis/shopee/shared.ts b/clis/shopee/shared.ts
new file mode 100644
index 000000000..ff9ae59f5
--- /dev/null
+++ b/clis/shopee/shared.ts
@@ -0,0 +1,213 @@
+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 = 1;
+export const SHOPDORA_NOT_LOGGED_IN_MESSAGE = 'Shopdora 未登录';
+
+type RawShopdoraLoginState = {
+ hasShopdoraLoginPage?: boolean;
+ hasPageDetailLoginTitle?: boolean;
+};
+
+export type ShopdoraLoginState = {
+ hasShopdoraLoginPage: boolean;
+ hasPageDetailLoginTitle: boolean;
+ loginMessage: string;
+};
+
+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({ time: 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() };
+ })()
+ `;
+}
+
+export function buildReadShopdoraLoginStateScript(): string {
+ return `
+ (() => ({
+ hasShopdoraLoginPage: Boolean(document.querySelector('.shopdoraLoginPage')),
+ hasPageDetailLoginTitle: Boolean(document.querySelector('.pageDetailLoginTitle')),
+ }))()
+ `;
+}
+
+export async function readShopdoraLoginState(page: IPage): Promise {
+ const result = await page.evaluate(buildReadShopdoraLoginStateScript());
+ const raw = (result && typeof result === 'object' ? result : {}) as RawShopdoraLoginState;
+ const hasShopdoraLoginPage = raw.hasShopdoraLoginPage === true;
+ const hasPageDetailLoginTitle = raw.hasPageDetailLoginTitle === true;
+ return {
+ hasShopdoraLoginPage,
+ hasPageDetailLoginTitle,
+ loginMessage: hasShopdoraLoginPage || hasPageDetailLoginTitle ? SHOPDORA_NOT_LOGGED_IN_MESSAGE : '',
+ };
+}
+
+export function appendShopdoraLoginMessage(message: string, loginMessage: string): string {
+ const base = String(message ?? '').trim();
+ const extra = String(loginMessage ?? '').trim();
+ if (!extra) return base;
+ return base ? `${base} ${extra}。` : extra;
+}
+
+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,
+ SHOPDORA_NOT_LOGGED_IN_MESSAGE,
+ appendShopdoraLoginMessage,
+ buildClearLocalStorageScript,
+ buildHumanPointerScript,
+ buildReadShopdoraLoginStateScript,
+ clearLocalStorageForUrlHost,
+ randomInRange,
+ readShopdoraLoginState,
+ 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..9557964dd 100644
--- a/src/browser/daemon-client.ts
+++ b/src/browser/daemon-client.ts
@@ -14,6 +14,7 @@ const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
const OPENCLI_HEADERS = { 'X-OpenCLI': '1' };
let _idCounter = 0;
+const boundTabIds = new Map();
function generateId(): string {
return `cmd_${Date.now()}_${++_idCounter}`;
@@ -21,7 +22,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 +32,7 @@ export interface DaemonCommand {
domain?: string;
matchDomain?: string;
matchPathPrefix?: string;
+ matchUrl?: string;
format?: 'png' | 'jpeg';
quality?: number;
fullPage?: boolean;
@@ -45,6 +47,10 @@ export interface DaemonCommand {
pattern?: string;
cdpMethod?: string;
cdpParams?: Record;
+ downloadTimeoutMs?: number;
+ downloadStartedAfterMs?: number;
+ downloadUrlPattern?: string;
+ downloadReferrerPattern?: string;
}
export interface DaemonResult {
@@ -60,6 +66,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 +113,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 +181,20 @@ export async function listSessions(): Promise {
return Array.isArray(result) ? result : [];
}
-export async function bindCurrentTab(workspace: string, opts: { matchDomain?: string; matchPathPrefix?: string } = {}): Promise {
- return sendCommand('bind-current', { workspace, ...opts });
+export function getBoundTabId(workspace: string): number | undefined {
+ return boundTabIds.get(workspace);
+}
+
+export async function bindCurrentTab(
+ workspace: string,
+ opts: { matchDomain?: string; matchPathPrefix?: string; matchUrl?: string } = {},
+): Promise {
+ const result = await sendCommand('bind-current', { workspace, ...opts });
+ if (result && typeof result === 'object' && 'tabId' in result) {
+ const tabId = Number((result as { tabId?: unknown }).tabId);
+ if (Number.isFinite(tabId) && tabId > 0) {
+ boundTabIds.set(workspace, tabId);
+ }
+ }
+ return result;
}
diff --git a/src/browser/dom-helpers.test.ts b/src/browser/dom-helpers.test.ts
index e14a2c247..6f2bb8765 100644
--- a/src/browser/dom-helpers.test.ts
+++ b/src/browser/dom-helpers.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { autoScrollJs, waitForCaptureJs, waitForSelectorJs } from './dom-helpers.js';
+import { autoScrollJs, clickJs, typeTextJs, waitForCaptureJs, waitForSelectorJs } from './dom-helpers.js';
describe('autoScrollJs', () => {
it('returns early without error when document.body is null', async () => {
@@ -112,3 +112,91 @@ describe('waitForSelectorJs', () => {
delete g.MutationObserver;
});
});
+
+describe('ref-or-selector helpers', () => {
+ it('clickJs falls back to a CSS selector when ref-style lookup would be invalid', () => {
+ const g = globalThis as any;
+ const calls: string[] = [];
+ const clicked: string[] = [];
+ const fakeEl = {
+ scrollIntoView: () => {},
+ getBoundingClientRect: () => ({ left: 10, top: 20, width: 30, height: 40 }),
+ click: () => { clicked.push('clicked'); },
+ };
+ g.document = {
+ querySelector: (selector: string) => {
+ calls.push(selector);
+ if (selector === '[data-opencli-ref="[data-test-id="primary-button"]"]') {
+ throw new SyntaxError('invalid selector');
+ }
+ if (selector === '[data-ref="[data-test-id="primary-button"]"]') {
+ throw new SyntaxError('invalid selector');
+ }
+ if (selector === '[data-test-id="primary-button"]') {
+ return fakeEl;
+ }
+ return null;
+ },
+ querySelectorAll: () => [],
+ };
+
+ const result = eval(clickJs('[data-test-id="primary-button"]')) as { status: string };
+
+ expect(result).toEqual({ status: 'clicked', x: 25, y: 40, w: 30, h: 40 });
+ expect(clicked).toEqual(['clicked']);
+ expect(calls).toContain('[data-test-id="primary-button"]');
+ delete g.document;
+ });
+
+ it('typeTextJs falls back to a CSS selector when ref-style lookup would be invalid', () => {
+ const g = globalThis as any;
+ const calls: string[] = [];
+ let assignedValue = '';
+ class FakeInputElement {
+ value = '';
+ focus() {}
+ dispatchEvent() { return true; }
+ }
+ Object.defineProperty(FakeInputElement.prototype, 'value', {
+ get() {
+ return assignedValue;
+ },
+ set(value: string) {
+ assignedValue = value;
+ },
+ configurable: true,
+ });
+ g.HTMLInputElement = FakeInputElement;
+ g.HTMLTextAreaElement = class {};
+ g.Event = class {
+ constructor(_type: string, _init?: unknown) {}
+ };
+ const fakeInput = new FakeInputElement();
+ g.document = {
+ querySelector: (selector: string) => {
+ calls.push(selector);
+ if (selector === '[data-opencli-ref="[data-test-id="date-input"]"]') {
+ throw new SyntaxError('invalid selector');
+ }
+ if (selector === '[data-ref="[data-test-id="date-input"]"]') {
+ throw new SyntaxError('invalid selector');
+ }
+ if (selector === '[data-test-id="date-input"]') {
+ return fakeInput;
+ }
+ return null;
+ },
+ querySelectorAll: () => [],
+ };
+
+ const result = eval(typeTextJs('[data-test-id="date-input"]', '2026-01-21'));
+
+ expect(result).toBe('typed');
+ expect(assignedValue).toBe('2026-01-21');
+ expect(calls).toContain('[data-test-id="date-input"]');
+ delete g.document;
+ delete g.HTMLInputElement;
+ delete g.HTMLTextAreaElement;
+ delete g.Event;
+ });
+});
diff --git a/src/browser/dom-helpers.ts b/src/browser/dom-helpers.ts
index bc5342ef4..e57e4541a 100644
--- a/src/browser/dom-helpers.ts
+++ b/src/browser/dom-helpers.ts
@@ -9,8 +9,11 @@
function resolveElementJs(safeRef: string, selectorSet: string): string {
return `
const ref = ${safeRef};
- let el = document.querySelector('[data-opencli-ref="' + ref + '"]');
- if (!el) el = document.querySelector('[data-ref="' + ref + '"]');
+ let el = null;
+ try { el = document.querySelector('[data-opencli-ref="' + ref + '"]'); } catch {}
+ if (!el) {
+ try { el = document.querySelector('[data-ref="' + ref + '"]'); } catch {}
+ }
if (!el && ref.match(/^[a-zA-Z#.\\[]/)) {
try { el = document.querySelector(ref); } catch {}
}
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..217f935e6 100644
--- a/src/browser/page.ts
+++ b/src/browser/page.ts
@@ -10,8 +10,8 @@
* chrome-extension:// tab that can't be debugged.
*/
-import type { BrowserCookie, ScreenshotOptions } from '../types.js';
-import { sendCommand } from './daemon-client.js';
+import type { BrowserCookie, BrowserDownloadResult, DownloadWaitOptions, ScreenshotOptions } from '../types.js';
+import { getBoundTabId, sendCommand } from './daemon-client.js';
import { wrapForEval } from './utils.js';
import { saveBase64ToFile } from '../utils.js';
import { generateStealthJs } from './stealth.js';
@@ -42,9 +42,10 @@ export class Page extends BasePage {
/** Helper: spread workspace + tabId into command params */
private _cmdOpts(): Record {
+ const effectiveTabId = this._tabId ?? getBoundTabId(this.workspace);
return {
workspace: this.workspace,
- ...(this._tabId !== undefined && { tabId: this._tabId }),
+ ...(effectiveTabId !== undefined && { tabId: effectiveTabId }),
};
}
@@ -201,6 +202,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. */