diff --git a/cli-manifest.json b/cli-manifest.json new file mode 100644 index 000000000..2141e06ab --- /dev/null +++ b/cli-manifest.json @@ -0,0 +1,5794 @@ +[ + { + "site": "bilibili", + "name": "hot", + "description": "B站热门视频", + "domain": "www.bilibili.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of videos" + } + ], + "columns": [ + "rank", + "title", + "author", + "play", + "danmaku" + ], + "pipeline": [ + { + "navigate": "https://www.bilibili.com" + }, + { + "evaluate": "(async () => {\n const res = await fetch('https://api.bilibili.com/x/web-interface/popular?ps=${{ args.limit }}&pn=1', {\n credentials: 'include'\n });\n const data = await res.json();\n return (data?.data?.list || []).map((item) => ({\n title: item.title,\n author: item.owner?.name,\n play: item.stat?.view,\n danmaku: item.stat?.danmaku,\n }));\n})()\n" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "author": "${{ item.author }}", + "play": "${{ item.play }}", + "danmaku": "${{ item.danmaku }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "bilibili/hot.yaml" + }, + { + "site": "bluesky", + "name": "feeds", + "description": "Popular Bluesky feed generators", + "domain": "public.api.bsky.app", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of feeds" + } + ], + "columns": [ + "rank", + "name", + "likes", + "creator", + "description" + ], + "pipeline": [ + { + "fetch": { + "url": "https://public.api.bsky.app/xrpc/app.bsky.unspecced.getPopularFeedGenerators?limit=${{ args.limit }}" + } + }, + { + "select": "feeds" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "name": "${{ item.displayName }}", + "likes": "${{ item.likeCount }}", + "creator": "${{ item.creator.handle }}", + "description": "${{ item.description }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "bluesky/feeds.yaml" + }, + { + "site": "bluesky", + "name": "followers", + "description": "List followers of a Bluesky user", + "domain": "public.api.bsky.app", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "handle", + "type": "str", + "required": true, + "positional": true, + "help": "Bluesky handle" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of followers" + } + ], + "columns": [ + "rank", + "handle", + "name", + "description" + ], + "pipeline": [ + { + "fetch": { + "url": "https://public.api.bsky.app/xrpc/app.bsky.graph.getFollowers?actor=${{ args.handle }}&limit=${{ args.limit }}" + } + }, + { + "select": "followers" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "handle": "${{ item.handle }}", + "name": "${{ item.displayName }}", + "description": "${{ item.description }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "bluesky/followers.yaml" + }, + { + "site": "bluesky", + "name": "following", + "description": "List accounts a Bluesky user is following", + "domain": "public.api.bsky.app", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "handle", + "type": "str", + "required": true, + "positional": true, + "help": "Bluesky handle" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of accounts" + } + ], + "columns": [ + "rank", + "handle", + "name", + "description" + ], + "pipeline": [ + { + "fetch": { + "url": "https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=${{ args.handle }}&limit=${{ args.limit }}" + } + }, + { + "select": "follows" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "handle": "${{ item.handle }}", + "name": "${{ item.displayName }}", + "description": "${{ item.description }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "bluesky/following.yaml" + }, + { + "site": "bluesky", + "name": "profile", + "description": "Get Bluesky user profile info", + "domain": "public.api.bsky.app", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "handle", + "type": "str", + "required": true, + "positional": true, + "help": "Bluesky handle (e.g. bsky.app, jay.bsky.team)" + } + ], + "columns": [ + "handle", + "name", + "followers", + "following", + "posts", + "description" + ], + "pipeline": [ + { + "fetch": { + "url": "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${{ args.handle }}" + } + }, + { + "map": { + "handle": "${{ item.handle }}", + "name": "${{ item.displayName }}", + "followers": "${{ item.followersCount }}", + "following": "${{ item.followsCount }}", + "posts": "${{ item.postsCount }}", + "description": "${{ item.description }}" + } + } + ], + "type": "yaml", + "sourceFile": "bluesky/profile.yaml" + }, + { + "site": "bluesky", + "name": "search", + "description": "Search Bluesky users", + "domain": "public.api.bsky.app", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "positional": false, + "help": "Number of results" + } + ], + "columns": [ + "rank", + "handle", + "name", + "followers", + "description" + ], + "pipeline": [ + { + "fetch": { + "url": "https://public.api.bsky.app/xrpc/app.bsky.actor.searchActors?q=${{ args.query }}&limit=${{ args.limit }}" + } + }, + { + "select": "actors" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "handle": "${{ item.handle }}", + "name": "${{ item.displayName }}", + "followers": "${{ item.followersCount }}", + "description": "${{ item.description }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "bluesky/search.yaml" + }, + { + "site": "bluesky", + "name": "starter-packs", + "description": "Get starter packs created by a Bluesky user", + "domain": "public.api.bsky.app", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "handle", + "type": "str", + "required": true, + "positional": true, + "help": "Bluesky handle" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "positional": false, + "help": "Number of starter packs" + } + ], + "columns": [ + "rank", + "name", + "description", + "members", + "joins" + ], + "pipeline": [ + { + "fetch": { + "url": "https://public.api.bsky.app/xrpc/app.bsky.graph.getActorStarterPacks?actor=${{ args.handle }}&limit=${{ args.limit }}" + } + }, + { + "select": "starterPacks" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "name": "${{ item.record.name }}", + "description": "${{ item.record.description }}", + "members": "${{ item.listItemCount }}", + "joins": "${{ item.joinedAllTimeCount }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "bluesky/starter-packs.yaml" + }, + { + "site": "bluesky", + "name": "thread", + "description": "Get a Bluesky post thread with replies", + "domain": "public.api.bsky.app", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "uri", + "type": "str", + "required": true, + "positional": true, + "help": "Post AT URI (at://did:.../app.bsky.feed.post/...) or bsky.app URL" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of replies" + } + ], + "columns": [ + "author", + "text", + "likes", + "reposts", + "replies_count" + ], + "pipeline": [ + { + "fetch": { + "url": "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${{ args.uri }}&depth=2" + } + }, + { + "select": "thread" + }, + { + "map": { + "author": "${{ item.post.author.handle }}", + "text": "${{ item.post.record.text }}", + "likes": "${{ item.post.likeCount }}", + "reposts": "${{ item.post.repostCount }}", + "replies_count": "${{ item.post.replyCount }}" + } + } + ], + "type": "yaml", + "sourceFile": "bluesky/thread.yaml" + }, + { + "site": "bluesky", + "name": "trending", + "description": "Trending topics on Bluesky", + "domain": "public.api.bsky.app", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of topics" + } + ], + "columns": [ + "rank", + "topic", + "link" + ], + "pipeline": [ + { + "fetch": { + "url": "https://public.api.bsky.app/xrpc/app.bsky.unspecced.getTrendingTopics" + } + }, + { + "select": "topics" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "topic": "${{ item.topic }}", + "link": "${{ item.link }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "bluesky/trending.yaml" + }, + { + "site": "bluesky", + "name": "user", + "description": "Get recent posts from a Bluesky user", + "domain": "public.api.bsky.app", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "handle", + "type": "str", + "required": true, + "positional": true, + "help": "Bluesky handle (e.g. bsky.app)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of posts" + } + ], + "columns": [ + "rank", + "text", + "likes", + "reposts", + "replies" + ], + "pipeline": [ + { + "fetch": { + "url": "https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${{ args.handle }}&limit=${{ args.limit }}" + } + }, + { + "select": "feed" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "text": "${{ item.post.record.text }}", + "likes": "${{ item.post.likeCount }}", + "reposts": "${{ item.post.repostCount }}", + "replies": "${{ item.post.replyCount }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "bluesky/user.yaml" + }, + { + "site": "devto", + "name": "tag", + "description": "Latest DEV.to articles for a specific tag", + "domain": "dev.to", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "tag", + "type": "str", + "required": true, + "positional": true, + "help": "Tag name (e.g. javascript, python, webdev)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of articles" + } + ], + "columns": [ + "rank", + "title", + "author", + "reactions", + "comments", + "tags" + ], + "pipeline": [ + { + "fetch": { + "url": "https://dev.to/api/articles?tag=${{ args.tag }}&per_page=${{ args.limit }}" + } + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "author": "${{ item.user.username }}", + "reactions": "${{ item.public_reactions_count }}", + "comments": "${{ item.comments_count }}", + "tags": "${{ item.tag_list | join(', ') }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "devto/tag.yaml" + }, + { + "site": "devto", + "name": "top", + "description": "Top DEV.to articles of the day", + "domain": "dev.to", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of articles" + } + ], + "columns": [ + "rank", + "title", + "author", + "reactions", + "comments", + "tags" + ], + "pipeline": [ + { + "fetch": { + "url": "https://dev.to/api/articles?top=1&per_page=${{ args.limit }}" + } + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "author": "${{ item.user.username }}", + "reactions": "${{ item.public_reactions_count }}", + "comments": "${{ item.comments_count }}", + "tags": "${{ item.tag_list | join(', ') }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "devto/top.yaml" + }, + { + "site": "devto", + "name": "user", + "description": "Recent DEV.to articles from a specific user", + "domain": "dev.to", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "DEV.to username (e.g. ben, thepracticaldev)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of articles" + } + ], + "columns": [ + "rank", + "title", + "reactions", + "comments", + "tags" + ], + "pipeline": [ + { + "fetch": { + "url": "https://dev.to/api/articles?username=${{ args.username }}&per_page=${{ args.limit }}" + } + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "reactions": "${{ item.public_reactions_count }}", + "comments": "${{ item.comments_count }}", + "tags": "${{ item.tag_list | join(', ') }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "devto/user.yaml" + }, + { + "site": "dictionary", + "name": "examples", + "description": "Read real-world example sentences utilizing the word", + "domain": "api.dictionaryapi.dev", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "word", + "type": "string", + "required": true, + "positional": true, + "help": "Word to get example sentences for" + } + ], + "columns": [ + "word", + "example" + ], + "pipeline": [ + { + "fetch": { + "url": "https://api.dictionaryapi.dev/api/v2/entries/en/${{ args.word | urlencode }}" + } + }, + { + "map": { + "word": "${{ item.word }}", + "example": "${{ (() => { if (item.meanings) { for (const m of item.meanings) { if (m.definitions) { for (const d of m.definitions) { if (d.example) return d.example; } } } } return 'No example found in API.'; })() }}" + } + }, + { + "limit": 1 + } + ], + "type": "yaml", + "sourceFile": "dictionary/examples.yaml" + }, + { + "site": "dictionary", + "name": "search", + "description": "Search the Free Dictionary API for definitions, parts of speech, and pronunciations.", + "domain": "api.dictionaryapi.dev", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "word", + "type": "string", + "required": true, + "positional": true, + "help": "Word to define (e.g., serendipity)" + } + ], + "columns": [ + "word", + "phonetic", + "type", + "definition" + ], + "pipeline": [ + { + "fetch": { + "url": "https://api.dictionaryapi.dev/api/v2/entries/en/${{ args.word | urlencode }}" + } + }, + { + "map": { + "word": "${{ item.word }}", + "phonetic": "${{ (() => { if (item.phonetic) return item.phonetic; if (item.phonetics) { for (const p of item.phonetics) { if (p.text) return p.text; } } return ''; })() }}", + "type": "${{ (() => { if (item.meanings && item.meanings[0] && item.meanings[0].partOfSpeech) return item.meanings[0].partOfSpeech; return 'N/A'; })() }}", + "definition": "${{ (() => { if (item.meanings && item.meanings[0] && item.meanings[0].definitions && item.meanings[0].definitions[0] && item.meanings[0].definitions[0].definition) return item.meanings[0].definitions[0].definition; return 'No definition found in API.'; })() }}" + } + }, + { + "limit": 1 + } + ], + "type": "yaml", + "sourceFile": "dictionary/search.yaml" + }, + { + "site": "dictionary", + "name": "synonyms", + "description": "Find synonyms for a specific word", + "domain": "api.dictionaryapi.dev", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "word", + "type": "string", + "required": true, + "positional": true, + "help": "Word to find synonyms for (e.g., serendipity)" + } + ], + "columns": [ + "word", + "synonyms" + ], + "pipeline": [ + { + "fetch": { + "url": "https://api.dictionaryapi.dev/api/v2/entries/en/${{ args.word | urlencode }}" + } + }, + { + "map": { + "word": "${{ item.word }}", + "synonyms": "${{ (() => { const s = new Set(); if (item.meanings) { for (const m of item.meanings) { if (m.synonyms) { for (const syn of m.synonyms) s.add(syn); } if (m.definitions) { for (const d of m.definitions) { if (d.synonyms) { for (const syn of d.synonyms) s.add(syn); } } } } } const arr = Array.from(s); return arr.length > 0 ? arr.slice(0, 5).join(', ') : 'No synonyms found in API.'; })() }}" + } + }, + { + "limit": 1 + } + ], + "type": "yaml", + "sourceFile": "dictionary/synonyms.yaml" + }, + { + "site": "douban", + "name": "subject", + "description": "获取电影详情", + "domain": "movie.douban.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "id", + "type": "str", + "required": true, + "positional": true, + "help": "电影 ID" + } + ], + "columns": [ + "id", + "title", + "originalTitle", + "year", + "rating", + "ratingCount", + "genres", + "directors", + "casts", + "country", + "duration", + "summary", + "url" + ], + "pipeline": [ + { + "navigate": "https://movie.douban.com/subject/${{ args.id }}" + }, + { + "evaluate": "(async () => {\n const id = '${{ args.id }}';\n\n // Wait for page to load\n await new Promise(r => setTimeout(r, 2000));\n\n // Extract title - v:itemreviewed contains \"中文名 OriginalName\"\n const titleEl = document.querySelector('span[property=\"v:itemreviewed\"]');\n const fullTitle = titleEl?.textContent?.trim() || '';\n\n // Split title and originalTitle\n // Douban format: \"中文名 OriginalName\" - split by first space that separates CJK from non-CJK\n let title = fullTitle;\n let originalTitle = '';\n const titleMatch = fullTitle.match(/^([\\u4e00-\\u9fff\\u3000-\\u303f\\uff00-\\uffef]+(?:\\s*[\\u4e00-\\u9fff\\u3000-\\u303f\\uff00-\\uffef·::!?]+)*)\\s+(.+)$/);\n if (titleMatch) {\n title = titleMatch[1].trim();\n originalTitle = titleMatch[2].trim();\n }\n\n // Extract year\n const yearEl = document.querySelector('.year');\n const year = yearEl?.textContent?.trim().replace(/[()()]/g, '') || '';\n\n // Extract rating\n const ratingEl = document.querySelector('strong[property=\"v:average\"]');\n const rating = parseFloat(ratingEl?.textContent || '0');\n\n // Extract rating count\n const ratingCountEl = document.querySelector('span[property=\"v:votes\"]');\n const ratingCount = parseInt(ratingCountEl?.textContent || '0', 10);\n\n // Extract genres\n const genreEls = document.querySelectorAll('span[property=\"v:genre\"]');\n const genres = Array.from(genreEls).map(el => el.textContent?.trim()).filter(Boolean).join(',');\n\n // Extract directors\n const directorEls = document.querySelectorAll('a[rel=\"v:directedBy\"]');\n const directors = Array.from(directorEls).map(el => el.textContent?.trim()).filter(Boolean).join(',');\n\n // Extract casts\n const castEls = document.querySelectorAll('a[rel=\"v:starring\"]');\n const casts = Array.from(castEls).slice(0, 5).map(el => el.textContent?.trim()).filter(Boolean);\n\n // Extract info section for country and duration\n const infoEl = document.querySelector('#info');\n const infoText = infoEl?.textContent || '';\n\n // Extract country/region from #info as list\n let country = [];\n const countryMatch = infoText.match(/制片国家\\/地区:\\s*([^\\n]+)/);\n if (countryMatch) {\n country = countryMatch[1].trim().split(/\\s*\\/\\s*/).filter(Boolean);\n }\n\n // Extract duration from #info as pure number in min\n const durationEl = document.querySelector('span[property=\"v:runtime\"]');\n let durationRaw = durationEl?.textContent?.trim() || '';\n if (!durationRaw) {\n const durationMatch = infoText.match(/片长:\\s*([^\\n]+)/);\n if (durationMatch) {\n durationRaw = durationMatch[1].trim();\n }\n }\n const durationNumMatch = durationRaw.match(/(\\d+)/);\n const duration = durationNumMatch ? parseInt(durationNumMatch[1], 10) : null;\n\n // Extract summary\n const summaryEl = document.querySelector('span[property=\"v:summary\"]');\n const summary = summaryEl?.textContent?.trim() || '';\n\n return [{\n id,\n title,\n originalTitle,\n year,\n rating,\n ratingCount,\n genres,\n directors,\n casts,\n country,\n duration,\n summary: summary.substring(0, 200),\n url: `https://movie.douban.com/subject/${id}`\n }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "douban/subject.yaml" + }, + { + "site": "douban", + "name": "top250", + "description": "豆瓣电影 Top250", + "domain": "movie.douban.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 250, + "required": false, + "positional": false, + "help": "返回结果数量" + } + ], + "columns": [ + "rank", + "id", + "title", + "rating", + "url" + ], + "pipeline": [ + { + "navigate": "https://movie.douban.com/top250" + }, + { + "evaluate": "async () => {\n const results = [];\n const limit = ${{ args.limit }};\n\n const parsePage = (doc) => {\n const items = doc.querySelectorAll('.item');\n for (const item of items) {\n if (results.length >= limit) break;\n\n const rankEl = item.querySelector('.pic em');\n const linkEl = item.querySelector('a');\n const titleEl = item.querySelector('.title');\n const ratingEl = item.querySelector('.rating_num');\n\n const href = linkEl?.href || '';\n const matchResult = href.match(/subject\\/(\\d+)/);\n const id = matchResult ? matchResult[1] : '';\n\n const title = titleEl?.textContent?.trim() || '';\n const rank = parseInt(rankEl?.textContent || '0', 10);\n const rating = ratingEl?.textContent?.trim() || '';\n\n if (id && title) {\n results.push({\n rank: rank || results.length + 1,\n id,\n title,\n rating: rating ? parseFloat(rating) : 0,\n url: href\n });\n }\n }\n };\n\n parsePage(document);\n\n for (let start = 25; start < 250 && results.length < limit; start += 25) {\n const resp = await fetch(`https://movie.douban.com/top250?start=${start}`);\n if (!resp.ok) break;\n const html = await resp.text();\n if (!html) break;\n\n const doc = new DOMParser().parseFromString(html, 'text/html');\n parsePage(doc);\n await new Promise(r => setTimeout(r, 150));\n }\n\n return results;\n}\n" + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "douban/top250.yaml" + }, + { + "site": "facebook", + "name": "add-friend", + "description": "Send a friend request on Facebook", + "domain": "www.facebook.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Facebook username or profile URL" + } + ], + "columns": [ + "status", + "username" + ], + "pipeline": [ + { + "navigate": { + "url": "https://www.facebook.com/${{ args.username }}", + "settleMs": 3000 + } + }, + { + "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n // Find \"Add Friend\" button\n const buttons = Array.from(document.querySelectorAll('[role=\"button\"]'));\n const addBtn = buttons.find(b => {\n const text = b.textContent.trim();\n return text === '加好友' || text === 'Add Friend' || text === 'Add friend';\n });\n\n if (!addBtn) {\n // Check if already friends\n const isFriend = buttons.some(b => {\n const t = b.textContent.trim();\n return t === '好友' || t === 'Friends' || t.includes('已发送') || t.includes('Pending');\n });\n if (isFriend) return [{ status: 'Already friends or request pending', username }];\n return [{ status: 'Add Friend button not found', username }];\n }\n\n addBtn.click();\n await new Promise(r => setTimeout(r, 1500));\n return [{ status: 'Friend request sent', username }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "facebook/add-friend.yaml" + }, + { + "site": "facebook", + "name": "events", + "description": "Browse Facebook event categories", + "domain": "www.facebook.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 15, + "required": false, + "positional": false, + "help": "Number of categories" + } + ], + "columns": [ + "index", + "name" + ], + "pipeline": [ + { + "navigate": { + "url": "https://www.facebook.com/events", + "settleMs": 3000 + } + }, + { + "evaluate": "(() => {\n const limit = ${{ args.limit }};\n // Try actual event items first\n const articles = document.querySelectorAll('[role=\"article\"]');\n if (articles.length > 0) {\n return Array.from(articles).slice(0, limit).map((el, i) => ({\n index: i + 1,\n name: el.textContent.trim().replace(/\\s+/g, ' ').substring(0, 120),\n }));\n }\n\n // List event categories from sidebar navigation\n const links = Array.from(document.querySelectorAll('[role=\"navigation\"] a'))\n .filter(a => {\n const href = a.href || '';\n const text = a.textContent.trim();\n return href.includes('/events/') && text.length > 1 && text.length < 60 &&\n !href.includes('create');\n });\n\n return links.slice(0, limit).map((a, i) => ({\n index: i + 1,\n name: a.textContent.trim(),\n }));\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "facebook/events.yaml" + }, + { + "site": "facebook", + "name": "feed", + "description": "Get your Facebook news feed", + "domain": "www.facebook.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "positional": false, + "help": "Number of posts" + } + ], + "columns": [ + "index", + "author", + "content", + "likes", + "comments", + "shares" + ], + "pipeline": [ + { + "navigate": { + "url": "https://www.facebook.com/", + "settleMs": 4000 + } + }, + { + "evaluate": "(() => {\n const limit = ${{ args.limit }};\n const posts = document.querySelectorAll('[role=\"article\"]');\n return Array.from(posts)\n .filter(el => {\n const text = el.textContent.trim();\n // Filter out \"People you may know\" suggestions (both CN and EN)\n return text.length > 30 &&\n !text.startsWith('可能认识') &&\n !text.startsWith('People you may know') &&\n !text.startsWith('People You May Know');\n })\n .slice(0, limit)\n .map((el, i) => {\n // Author from header link\n const headerLink = el.querySelector('h2 a, h3 a, h4 a, strong a');\n const author = headerLink ? headerLink.textContent.trim() : '';\n\n // Post text: grab visible spans, filter noise\n const spans = Array.from(el.querySelectorAll('div[dir=\"auto\"]'))\n .map(s => s.textContent.trim())\n .filter(t => t.length > 10 && t.length < 500);\n const content = spans.length > 0 ? spans[0] : '';\n\n // Engagement: find like/comment/share counts (CN + EN)\n const allText = el.textContent;\n const likesMatch = allText.match(/所有心情:([\\d,.\\s]*[\\d万亿KMk]+)/) ||\n allText.match(/All:\\s*([\\d,.KMk]+)/) ||\n allText.match(/([\\d,.KMk]+)\\s*(?:likes?|reactions?)/i);\n const commentsMatch = allText.match(/([\\d,.]+\\s*[万亿]?)\\s*条评论/) ||\n allText.match(/([\\d,.KMk]+)\\s*comments?/i);\n const sharesMatch = allText.match(/([\\d,.]+\\s*[万亿]?)\\s*次分享/) ||\n allText.match(/([\\d,.KMk]+)\\s*shares?/i);\n\n return {\n index: i + 1,\n author: author.substring(0, 50),\n content: content.replace(/\\n/g, ' ').substring(0, 120),\n likes: likesMatch ? likesMatch[1] : '-',\n comments: commentsMatch ? commentsMatch[1] : '-',\n shares: sharesMatch ? sharesMatch[1] : '-',\n };\n });\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "facebook/feed.yaml" + }, + { + "site": "facebook", + "name": "friends", + "description": "Get Facebook friend suggestions", + "domain": "www.facebook.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "positional": false, + "help": "Number of friend suggestions" + } + ], + "columns": [ + "index", + "name", + "mutual" + ], + "pipeline": [ + { + "navigate": { + "url": "https://www.facebook.com/friends", + "settleMs": 3000 + } + }, + { + "evaluate": "(() => {\n const limit = ${{ args.limit }};\n const items = document.querySelectorAll('[role=\"listitem\"]');\n return Array.from(items)\n .slice(0, limit)\n .map((el, i) => {\n const text = el.textContent.trim().replace(/\\s+/g, ' ');\n // Extract mutual info if present (before name extraction to avoid pollution)\n const mutualMatch = text.match(/([\\d,]+)\\s*位.*(?:关注|共同|mutual)/);\n // Extract name: remove mutual info, action buttons, etc.\n let name = text\n .replace(/[\\d,]+\\s*位.*(?:关注了|共同好友|mutual friends?)/, '')\n .replace(/加好友.*/, '').replace(/Add [Ff]riend.*/, '')\n .replace(/移除$/, '').replace(/Remove$/, '')\n .trim();\n return {\n index: i + 1,\n name: name.substring(0, 50),\n mutual: mutualMatch ? mutualMatch[1] : '-',\n };\n })\n .filter(item => item.name.length > 0);\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "facebook/friends.yaml" + }, + { + "site": "facebook", + "name": "groups", + "description": "List your Facebook groups", + "domain": "www.facebook.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of groups" + } + ], + "columns": [ + "index", + "name", + "last_post", + "url" + ], + "pipeline": [ + { + "navigate": { + "url": "https://www.facebook.com/groups/feed/", + "settleMs": 3000 + } + }, + { + "evaluate": "(() => {\n const limit = ${{ args.limit }};\n const links = Array.from(document.querySelectorAll('a'))\n .filter(a => {\n const href = a.href || '';\n return href.includes('/groups/') &&\n !href.includes('/feed') &&\n !href.includes('/discover') &&\n !href.includes('/joins') &&\n !href.includes('category=create') &&\n a.textContent.trim().length > 2;\n });\n\n // Deduplicate by href\n const seen = new Set();\n const groups = [];\n for (const a of links) {\n const href = a.href.split('?')[0];\n if (seen.has(href)) continue;\n seen.add(href);\n const raw = a.textContent.trim().replace(/\\s+/g, ' ');\n // Split name from \"上次发帖\" info\n const parts = raw.split(/上次发帖|Last post/);\n groups.push({\n name: (parts[0] || '').trim().substring(0, 60),\n last_post: parts[1] ? parts[1].replace(/^[::]/, '').trim() : '-',\n url: href,\n });\n }\n return groups.slice(0, limit).map((g, i) => ({ index: i + 1, ...g }));\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "facebook/groups.yaml" + }, + { + "site": "facebook", + "name": "join-group", + "description": "Join a Facebook group", + "domain": "www.facebook.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "group", + "type": "str", + "required": true, + "positional": true, + "help": "Group ID or URL path (e.g. '1876150192925481' or group name)" + } + ], + "columns": [ + "status", + "group" + ], + "pipeline": [ + { + "navigate": { + "url": "https://www.facebook.com/groups/${{ args.group }}", + "settleMs": 3000 + } + }, + { + "evaluate": "(async () => {\n const group = ${{ args.group | json }};\n const groupName = document.querySelector('h1')?.textContent?.trim() || group;\n\n // Find \"Join Group\" button\n const buttons = Array.from(document.querySelectorAll('[role=\"button\"]'));\n const joinBtn = buttons.find(b => {\n const text = b.textContent.trim();\n return text === '加入小组' || text === 'Join group' || text === 'Join Group';\n });\n\n if (!joinBtn) {\n const isMember = buttons.some(b => {\n const t = b.textContent.trim();\n return t === '已加入' || t === 'Joined' || t === '成员' || t === 'Member';\n });\n if (isMember) return [{ status: 'Already a member', group: groupName }];\n return [{ status: 'Join button not found', group: groupName }];\n }\n\n joinBtn.click();\n await new Promise(r => setTimeout(r, 1500));\n return [{ status: 'Join request sent', group: groupName }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "facebook/join-group.yaml" + }, + { + "site": "facebook", + "name": "memories", + "description": "Get your Facebook memories (On This Day)", + "domain": "www.facebook.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "positional": false, + "help": "Number of memories" + } + ], + "columns": [ + "index", + "source", + "content", + "time" + ], + "pipeline": [ + { + "navigate": { + "url": "https://www.facebook.com/onthisday", + "settleMs": 4000 + } + }, + { + "evaluate": "(() => {\n const limit = ${{ args.limit }};\n const articles = document.querySelectorAll('[role=\"article\"]');\n return Array.from(articles)\n .slice(0, limit)\n .map((el, i) => {\n const headerLink = el.querySelector('h2 a, h3 a, h4 a, strong a');\n const spans = Array.from(el.querySelectorAll('div[dir=\"auto\"]'))\n .map(s => s.textContent.trim())\n .filter(t => t.length > 5 && t.length < 500);\n const timeEl = el.querySelector('a[href*=\"/posts/\"] span, a[href*=\"story_fbid\"] span');\n return {\n index: i + 1,\n source: headerLink ? headerLink.textContent.trim().substring(0, 50) : '-',\n content: (spans[0] || '').replace(/\\n/g, ' ').substring(0, 150),\n time: timeEl ? timeEl.textContent.trim() : '-',\n };\n })\n .filter(item => item.content.length > 0 || item.source !== '-');\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "facebook/memories.yaml" + }, + { + "site": "facebook", + "name": "notifications", + "description": "Get recent Facebook notifications", + "domain": "www.facebook.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 15, + "required": false, + "positional": false, + "help": "Number of notifications" + } + ], + "columns": [ + "index", + "text", + "time" + ], + "pipeline": [ + { + "navigate": { + "url": "https://www.facebook.com/notifications", + "settleMs": 3000 + } + }, + { + "evaluate": "(() => {\n const limit = ${{ args.limit }};\n const items = document.querySelectorAll('[role=\"listitem\"]');\n return Array.from(items)\n .filter(el => el.querySelectorAll('a').length > 0)\n .slice(0, limit)\n .map((el, i) => {\n const raw = el.textContent.trim().replace(/\\s+/g, ' ');\n // Remove leading \"未读\" and trailing \"标记为已读\"\n const cleaned = raw.replace(/^未读/, '').replace(/标记为已读$/, '').replace(/^Unread/, '').replace(/Mark as read$/, '').trim();\n // Try to extract time (last segment like \"11小时\", \"5天\", \"1周\")\n const timeMatch = cleaned.match(/(\\d+\\s*(?:分钟|小时|天|周|个月|minutes?|hours?|days?|weeks?|months?))\\s*$/);\n const time = timeMatch ? timeMatch[1] : '';\n const text = timeMatch ? cleaned.slice(0, -timeMatch[0].length).trim() : cleaned;\n return {\n index: i + 1,\n text: text.substring(0, 150),\n time: time || '-',\n };\n });\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "facebook/notifications.yaml" + }, + { + "site": "facebook", + "name": "profile", + "description": "Get Facebook user/page profile info", + "domain": "www.facebook.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Facebook username or page name" + } + ], + "columns": [ + "name", + "username", + "friends", + "followers", + "url" + ], + "pipeline": [ + { + "navigate": { + "url": "https://www.facebook.com/${{ args.username }}", + "settleMs": 3000 + } + }, + { + "evaluate": "(() => {\n const h1 = document.querySelector('h1');\n let name = h1 ? h1.textContent.trim() : '';\n\n // Find friends/followers links\n const links = Array.from(document.querySelectorAll('a'));\n const friendsLink = links.find(a => a.href && a.href.includes('/friends'));\n const followersLink = links.find(a => a.href && a.href.includes('/followers'));\n\n return [{\n name: name,\n username: ${{ args.username | json }},\n friends: friendsLink ? friendsLink.textContent.trim() : '-',\n followers: followersLink ? followersLink.textContent.trim() : '-',\n url: window.location.href,\n }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "facebook/profile.yaml" + }, + { + "site": "facebook", + "name": "search", + "description": "Search Facebook for people, pages, or posts", + "domain": "www.facebook.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "positional": false, + "help": "Number of results" + } + ], + "columns": [ + "index", + "title", + "text", + "url" + ], + "pipeline": [ + { + "navigate": "https://www.facebook.com" + }, + { + "navigate": { + "url": "https://www.facebook.com/search/top?q=${{ args.query | urlencode }}", + "settleMs": 4000 + } + }, + { + "evaluate": "(async () => {\n const limit = ${{ args.limit }};\n // Search results are typically in role=\"article\" or role=\"listitem\"\n let items = document.querySelectorAll('[role=\"article\"]');\n if (items.length === 0) {\n items = document.querySelectorAll('[role=\"listitem\"]');\n }\n return Array.from(items)\n .filter(el => el.textContent.trim().length > 20)\n .slice(0, limit)\n .map((el, i) => {\n const link = el.querySelector('a[href*=\"facebook.com/\"]');\n const heading = el.querySelector('h2, h3, h4, strong');\n return {\n index: i + 1,\n title: heading ? heading.textContent.trim().substring(0, 80) : '',\n text: el.textContent.trim().replace(/\\s+/g, ' ').substring(0, 150),\n url: link ? link.href.split('?')[0] : '',\n };\n });\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "facebook/search.yaml" + }, + { + "site": "hackernews", + "name": "ask", + "description": "Hacker News Ask HN posts", + "domain": "news.ycombinator.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of stories" + } + ], + "columns": [ + "rank", + "title", + "score", + "author", + "comments" + ], + "pipeline": [ + { + "fetch": { + "url": "https://hacker-news.firebaseio.com/v0/askstories.json" + } + }, + { + "limit": "${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}" + }, + { + "map": { + "id": "${{ item }}" + } + }, + { + "fetch": { + "url": "https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json" + } + }, + { + "filter": "item.title && !item.deleted && !item.dead" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "score": "${{ item.score }}", + "author": "${{ item.by }}", + "comments": "${{ item.descendants }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "hackernews/ask.yaml" + }, + { + "site": "hackernews", + "name": "best", + "description": "Hacker News best stories", + "domain": "news.ycombinator.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of stories" + } + ], + "columns": [ + "rank", + "title", + "score", + "author", + "comments" + ], + "pipeline": [ + { + "fetch": { + "url": "https://hacker-news.firebaseio.com/v0/beststories.json" + } + }, + { + "limit": "${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}" + }, + { + "map": { + "id": "${{ item }}" + } + }, + { + "fetch": { + "url": "https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json" + } + }, + { + "filter": "item.title && !item.deleted && !item.dead" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "score": "${{ item.score }}", + "author": "${{ item.by }}", + "comments": "${{ item.descendants }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "hackernews/best.yaml" + }, + { + "site": "hackernews", + "name": "jobs", + "description": "Hacker News job postings", + "domain": "news.ycombinator.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of job postings" + } + ], + "columns": [ + "rank", + "title", + "author", + "url" + ], + "pipeline": [ + { + "fetch": { + "url": "https://hacker-news.firebaseio.com/v0/jobstories.json" + } + }, + { + "limit": "${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}" + }, + { + "map": { + "id": "${{ item }}" + } + }, + { + "fetch": { + "url": "https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json" + } + }, + { + "filter": "item.title && !item.deleted && !item.dead" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "author": "${{ item.by }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "hackernews/jobs.yaml" + }, + { + "site": "hackernews", + "name": "new", + "description": "Hacker News newest stories", + "domain": "news.ycombinator.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of stories" + } + ], + "columns": [ + "rank", + "title", + "score", + "author", + "comments" + ], + "pipeline": [ + { + "fetch": { + "url": "https://hacker-news.firebaseio.com/v0/newstories.json" + } + }, + { + "limit": "${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}" + }, + { + "map": { + "id": "${{ item }}" + } + }, + { + "fetch": { + "url": "https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json" + } + }, + { + "filter": "item.title && !item.deleted && !item.dead" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "score": "${{ item.score }}", + "author": "${{ item.by }}", + "comments": "${{ item.descendants }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "hackernews/new.yaml" + }, + { + "site": "hackernews", + "name": "search", + "description": "Search Hacker News stories", + "domain": "news.ycombinator.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of results" + }, + { + "name": "sort", + "type": "str", + "default": "relevance", + "required": false, + "positional": false, + "help": "Sort by relevance or date", + "choices": [ + "relevance", + "date" + ] + } + ], + "columns": [ + "rank", + "title", + "score", + "author", + "comments", + "url" + ], + "pipeline": [ + { + "fetch": { + "url": "https://hn.algolia.com/api/v1/${{ args.sort === 'date' ? 'search_by_date' : 'search' }}", + "params": { + "query": "${{ args.query }}", + "tags": "story", + "hitsPerPage": "${{ args.limit }}" + } + } + }, + { + "select": "hits" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "score": "${{ item.points }}", + "author": "${{ item.author }}", + "comments": "${{ item.num_comments }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "hackernews/search.yaml" + }, + { + "site": "hackernews", + "name": "show", + "description": "Hacker News Show HN posts", + "domain": "news.ycombinator.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of stories" + } + ], + "columns": [ + "rank", + "title", + "score", + "author", + "comments" + ], + "pipeline": [ + { + "fetch": { + "url": "https://hacker-news.firebaseio.com/v0/showstories.json" + } + }, + { + "limit": "${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}" + }, + { + "map": { + "id": "${{ item }}" + } + }, + { + "fetch": { + "url": "https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json" + } + }, + { + "filter": "item.title && !item.deleted && !item.dead" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "score": "${{ item.score }}", + "author": "${{ item.by }}", + "comments": "${{ item.descendants }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "hackernews/show.yaml" + }, + { + "site": "hackernews", + "name": "top", + "description": "Hacker News top stories", + "domain": "news.ycombinator.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of stories" + } + ], + "columns": [ + "rank", + "title", + "score", + "author", + "comments" + ], + "pipeline": [ + { + "fetch": { + "url": "https://hacker-news.firebaseio.com/v0/topstories.json" + } + }, + { + "limit": "${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}" + }, + { + "map": { + "id": "${{ item }}" + } + }, + { + "fetch": { + "url": "https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json" + } + }, + { + "filter": "item.title && !item.deleted && !item.dead" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "score": "${{ item.score }}", + "author": "${{ item.by }}", + "comments": "${{ item.descendants }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "hackernews/top.yaml" + }, + { + "site": "hackernews", + "name": "user", + "description": "Hacker News user profile", + "domain": "news.ycombinator.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "HN username" + } + ], + "columns": [ + "username", + "karma", + "created", + "about" + ], + "pipeline": [ + { + "fetch": { + "url": "https://hacker-news.firebaseio.com/v0/user/${{ args.username }}.json" + } + }, + { + "map": { + "username": "${{ item.id }}", + "karma": "${{ item.karma }}", + "created": "${{ item.created ? new Date(item.created * 1000).toISOString().slice(0, 10) : '' }}", + "about": "${{ item.about }}" + } + } + ], + "type": "yaml", + "sourceFile": "hackernews/user.yaml" + }, + { + "site": "hupu", + "name": "hot", + "description": "虎扑热门帖子", + "domain": "bbs.hupu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of hot posts" + } + ], + "columns": [ + "rank", + "title", + "url" + ], + "pipeline": [ + { + "navigate": "https://bbs.hupu.com/" + }, + { + "evaluate": "(async () => {\n // 从HTML中提取帖子信息(适配新的HTML结构)\n const html = document.documentElement.outerHTML;\n const posts = [];\n\n // 匹配当前虎扑页面结构的正则表达式\n // 结构: 标题\n const regex = /]*href=\"\\/(\\d{9})\\.html\"[^>]*>]*class=\"t-title\"[^>]*>([^<]+)<\\/span><\\/a>/g;\n let match;\n\n while ((match = regex.exec(html)) !== null && posts.length < ${{ args.limit }}) {\n posts.push({\n tid: match[1],\n title: match[2].trim()\n });\n }\n\n return posts;\n})()\n" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "url": "https://bbs.hupu.com/${{ item.tid }}.html" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "hupu/hot.yaml" + }, + { + "site": "instagram", + "name": "comment", + "description": "Comment on an Instagram post", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Username of the post author" + }, + { + "name": "text", + "type": "str", + "required": true, + "positional": true, + "help": "Comment text" + }, + { + "name": "index", + "type": "int", + "default": 1, + "required": false, + "positional": false, + "help": "Post index (1 = most recent)" + } + ], + "columns": [ + "status", + "user", + "text" + ], + "pipeline": [ + { + "navigate": "https://www.instagram.com" + }, + { + "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const commentText = ${{ args.text | json }};\n const idx = ${{ args.index }} - 1;\n const headers = { 'X-IG-App-ID': '936619743392459' };\n const opts = { credentials: 'include', headers };\n\n const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts);\n if (!r1.ok) throw new Error('User not found: ' + username);\n const userId = (await r1.json())?.data?.user?.id;\n\n const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts);\n const posts = (await r2.json())?.items || [];\n if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found');\n const pk = posts[idx].pk;\n\n const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';\n const r3 = await fetch('https://www.instagram.com/api/v1/web/comments/' + pk + '/add/', {\n method: 'POST', credentials: 'include',\n headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' },\n body: 'comment_text=' + encodeURIComponent(commentText),\n });\n if (!r3.ok) throw new Error('Failed to comment: HTTP ' + r3.status);\n return [{ status: 'Commented', user: username, text: commentText }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "instagram/comment.yaml" + }, + { + "site": "instagram", + "name": "explore", + "description": "Instagram explore/discover trending posts", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of posts" + } + ], + "columns": [ + "rank", + "user", + "caption", + "likes", + "comments", + "type" + ], + "pipeline": [ + { + "navigate": "https://www.instagram.com" + }, + { + "evaluate": "(async () => {\n const limit = ${{ args.limit }};\n const res = await fetch(\n 'https://www.instagram.com/api/v1/discover/web/explore_grid/',\n {\n credentials: 'include',\n headers: { 'X-IG-App-ID': '936619743392459' }\n }\n );\n if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram');\n const data = await res.json();\n const posts = [];\n for (const sec of (data?.sectional_items || [])) {\n for (const m of (sec?.layout_content?.medias || [])) {\n const media = m?.media;\n if (media) posts.push({\n user: media.user?.username || '',\n caption: (media.caption?.text || '').replace(/\\n/g, ' ').substring(0, 100),\n likes: media.like_count ?? 0,\n comments: media.comment_count ?? 0,\n type: media.media_type === 1 ? 'photo' : media.media_type === 2 ? 'video' : 'carousel',\n });\n }\n }\n return posts.slice(0, limit).map((p, i) => ({ rank: i + 1, ...p }));\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "instagram/explore.yaml" + }, + { + "site": "instagram", + "name": "follow", + "description": "Follow an Instagram user", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Instagram username to follow" + } + ], + "columns": [ + "status", + "username" + ], + "pipeline": [ + { + "navigate": "https://www.instagram.com" + }, + { + "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const headers = { 'X-IG-App-ID': '936619743392459' };\n const opts = { credentials: 'include', headers };\n\n // Get user ID\n const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts);\n if (!r1.ok) throw new Error('User not found: ' + username);\n const d1 = await r1.json();\n const userId = d1?.data?.user?.id;\n if (!userId) throw new Error('User not found: ' + username);\n\n const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';\n const r2 = await fetch('https://www.instagram.com/api/v1/friendships/create/' + userId + '/', {\n method: 'POST',\n credentials: 'include',\n headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' },\n });\n if (!r2.ok) throw new Error('Failed to follow: HTTP ' + r2.status);\n const d2 = await r2.json();\n const status = d2?.friendship_status?.following ? 'Following' : d2?.friendship_status?.outgoing_request ? 'Request sent' : 'Followed';\n return [{ status, username }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "instagram/follow.yaml" + }, + { + "site": "instagram", + "name": "followers", + "description": "List followers of an Instagram user", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Instagram username" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of followers" + } + ], + "columns": [ + "rank", + "username", + "name", + "verified", + "private" + ], + "pipeline": [ + { + "navigate": "https://www.instagram.com" + }, + { + "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const limit = ${{ args.limit }};\n const headers = { 'X-IG-App-ID': '936619743392459' };\n const opts = { credentials: 'include', headers };\n\n const r1 = await fetch(\n 'https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username),\n opts\n );\n if (!r1.ok) throw new Error('HTTP ' + r1.status + ' - make sure you are logged in to Instagram');\n const d1 = await r1.json();\n const userId = d1?.data?.user?.id;\n if (!userId) throw new Error('User not found: ' + username);\n\n const r2 = await fetch(\n 'https://www.instagram.com/api/v1/friendships/' + userId + '/followers/?count=' + limit,\n opts\n );\n if (!r2.ok) throw new Error('Failed to fetch followers: HTTP ' + r2.status);\n const d2 = await r2.json();\n return (d2?.users || []).slice(0, limit).map((u, i) => ({\n rank: i + 1,\n username: u.username || '',\n name: u.full_name || '',\n verified: u.is_verified ? 'Yes' : 'No',\n private: u.is_private ? 'Yes' : 'No',\n }));\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "instagram/followers.yaml" + }, + { + "site": "instagram", + "name": "following", + "description": "List accounts an Instagram user is following", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Instagram username" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of accounts" + } + ], + "columns": [ + "rank", + "username", + "name", + "verified", + "private" + ], + "pipeline": [ + { + "navigate": "https://www.instagram.com" + }, + { + "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const limit = ${{ args.limit }};\n const headers = { 'X-IG-App-ID': '936619743392459' };\n const opts = { credentials: 'include', headers };\n\n const r1 = await fetch(\n 'https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username),\n opts\n );\n if (!r1.ok) throw new Error('HTTP ' + r1.status + ' - make sure you are logged in to Instagram');\n const d1 = await r1.json();\n const userId = d1?.data?.user?.id;\n if (!userId) throw new Error('User not found: ' + username);\n\n const r2 = await fetch(\n 'https://www.instagram.com/api/v1/friendships/' + userId + '/following/?count=' + limit,\n opts\n );\n if (!r2.ok) throw new Error('Failed to fetch following: HTTP ' + r2.status);\n const d2 = await r2.json();\n return (d2?.users || []).slice(0, limit).map((u, i) => ({\n rank: i + 1,\n username: u.username || '',\n name: u.full_name || '',\n verified: u.is_verified ? 'Yes' : 'No',\n private: u.is_private ? 'Yes' : 'No',\n }));\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "instagram/following.yaml" + }, + { + "site": "instagram", + "name": "like", + "description": "Like an Instagram post", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Username of the post author" + }, + { + "name": "index", + "type": "int", + "default": 1, + "required": false, + "positional": false, + "help": "Post index (1 = most recent)" + } + ], + "columns": [ + "status", + "user", + "post" + ], + "pipeline": [ + { + "navigate": "https://www.instagram.com" + }, + { + "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const idx = ${{ args.index }} - 1;\n const headers = { 'X-IG-App-ID': '936619743392459' };\n const opts = { credentials: 'include', headers };\n\n const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts);\n if (!r1.ok) throw new Error('User not found: ' + username);\n const userId = (await r1.json())?.data?.user?.id;\n\n const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts);\n const posts = (await r2.json())?.items || [];\n if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found, user has ' + posts.length + ' recent posts');\n const pk = posts[idx].pk;\n const caption = (posts[idx].caption?.text || '').substring(0, 60);\n\n const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';\n const r3 = await fetch('https://www.instagram.com/api/v1/web/likes/' + pk + '/like/', {\n method: 'POST', credentials: 'include',\n headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' },\n });\n if (!r3.ok) throw new Error('Failed to like: HTTP ' + r3.status);\n return [{ status: 'Liked', user: username, post: caption || '(post #' + (idx+1) + ')' }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "instagram/like.yaml" + }, + { + "site": "instagram", + "name": "profile", + "description": "Get Instagram user profile info", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Instagram username" + } + ], + "columns": [ + "username", + "name", + "followers", + "following", + "posts", + "verified", + "bio" + ], + "pipeline": [ + { + "navigate": "https://www.instagram.com" + }, + { + "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const res = await fetch(\n 'https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username),\n {\n credentials: 'include',\n headers: { 'X-IG-App-ID': '936619743392459' }\n }\n );\n if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram');\n const data = await res.json();\n const u = data?.data?.user;\n if (!u) throw new Error('User not found: ' + username);\n return [{\n username: u.username,\n name: u.full_name || '',\n bio: (u.biography || '').replace(/\\n/g, ' ').substring(0, 120),\n followers: u.edge_followed_by?.count ?? 0,\n following: u.edge_follow?.count ?? 0,\n posts: u.edge_owner_to_timeline_media?.count ?? 0,\n verified: u.is_verified ? 'Yes' : 'No',\n url: 'https://www.instagram.com/' + u.username,\n }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "instagram/profile.yaml" + }, + { + "site": "instagram", + "name": "save", + "description": "Save (bookmark) an Instagram post", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Username of the post author" + }, + { + "name": "index", + "type": "int", + "default": 1, + "required": false, + "positional": false, + "help": "Post index (1 = most recent)" + } + ], + "columns": [ + "status", + "user", + "post" + ], + "pipeline": [ + { + "navigate": "https://www.instagram.com" + }, + { + "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const idx = ${{ args.index }} - 1;\n const headers = { 'X-IG-App-ID': '936619743392459' };\n const opts = { credentials: 'include', headers };\n\n const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts);\n if (!r1.ok) throw new Error('User not found: ' + username);\n const userId = (await r1.json())?.data?.user?.id;\n\n const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts);\n const posts = (await r2.json())?.items || [];\n if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found');\n const pk = posts[idx].pk;\n const caption = (posts[idx].caption?.text || '').substring(0, 60);\n\n const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';\n const r3 = await fetch('https://www.instagram.com/api/v1/web/save/' + pk + '/save/', {\n method: 'POST', credentials: 'include',\n headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' },\n });\n if (!r3.ok) throw new Error('Failed to save: HTTP ' + r3.status);\n return [{ status: 'Saved', user: username, post: caption || '(post #' + (idx+1) + ')' }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "instagram/save.yaml" + }, + { + "site": "instagram", + "name": "saved", + "description": "Get your saved Instagram posts", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of saved posts" + } + ], + "columns": [ + "index", + "user", + "caption", + "likes", + "comments", + "type" + ], + "pipeline": [ + { + "navigate": "https://www.instagram.com" + }, + { + "evaluate": "(async () => {\n const limit = ${{ args.limit }};\n const res = await fetch(\n 'https://www.instagram.com/api/v1/feed/saved/posts/',\n {\n credentials: 'include',\n headers: { 'X-IG-App-ID': '936619743392459' }\n }\n );\n if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram');\n const data = await res.json();\n return (data?.items || []).slice(0, limit).map((item, i) => {\n const m = item?.media;\n return {\n index: i + 1,\n user: m?.user?.username || '',\n caption: (m?.caption?.text || '').replace(/\\n/g, ' ').substring(0, 100),\n likes: m?.like_count ?? 0,\n comments: m?.comment_count ?? 0,\n type: m?.media_type === 1 ? 'photo' : m?.media_type === 2 ? 'video' : 'carousel',\n };\n });\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "instagram/saved.yaml" + }, + { + "site": "instagram", + "name": "search", + "description": "Search Instagram users", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "positional": false, + "help": "Number of results" + } + ], + "columns": [ + "rank", + "username", + "name", + "verified", + "private", + "url" + ], + "pipeline": [ + { + "navigate": "https://www.instagram.com" + }, + { + "evaluate": "(async () => {\n const query = ${{ args.query | json }};\n const limit = ${{ args.limit }};\n const res = await fetch(\n 'https://www.instagram.com/web/search/topsearch/?query=' + encodeURIComponent(query) + '&context=user',\n {\n credentials: 'include',\n headers: { 'X-IG-App-ID': '936619743392459' }\n }\n );\n if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram');\n const data = await res.json();\n const users = (data?.users || []).slice(0, limit);\n return users.map((item, i) => ({\n rank: i + 1,\n username: item.user?.username || '',\n name: item.user?.full_name || '',\n verified: item.user?.is_verified ? 'Yes' : 'No',\n private: item.user?.is_private ? 'Yes' : 'No',\n url: 'https://www.instagram.com/' + (item.user?.username || ''),\n }));\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "instagram/search.yaml" + }, + { + "site": "instagram", + "name": "unfollow", + "description": "Unfollow an Instagram user", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Instagram username to unfollow" + } + ], + "columns": [ + "status", + "username" + ], + "pipeline": [ + { + "navigate": "https://www.instagram.com" + }, + { + "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const headers = { 'X-IG-App-ID': '936619743392459' };\n const opts = { credentials: 'include', headers };\n\n const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts);\n if (!r1.ok) throw new Error('User not found: ' + username);\n const d1 = await r1.json();\n const userId = d1?.data?.user?.id;\n if (!userId) throw new Error('User not found: ' + username);\n\n const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';\n const r2 = await fetch('https://www.instagram.com/api/v1/friendships/destroy/' + userId + '/', {\n method: 'POST',\n credentials: 'include',\n headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' },\n });\n if (!r2.ok) throw new Error('Failed to unfollow: HTTP ' + r2.status);\n return [{ status: 'Unfollowed', username }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "instagram/unfollow.yaml" + }, + { + "site": "instagram", + "name": "unlike", + "description": "Unlike an Instagram post", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Username of the post author" + }, + { + "name": "index", + "type": "int", + "default": 1, + "required": false, + "positional": false, + "help": "Post index (1 = most recent)" + } + ], + "columns": [ + "status", + "user", + "post" + ], + "pipeline": [ + { + "navigate": "https://www.instagram.com" + }, + { + "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const idx = ${{ args.index }} - 1;\n const headers = { 'X-IG-App-ID': '936619743392459' };\n const opts = { credentials: 'include', headers };\n\n const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts);\n if (!r1.ok) throw new Error('User not found: ' + username);\n const userId = (await r1.json())?.data?.user?.id;\n\n const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts);\n const posts = (await r2.json())?.items || [];\n if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found');\n const pk = posts[idx].pk;\n const caption = (posts[idx].caption?.text || '').substring(0, 60);\n\n const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';\n const r3 = await fetch('https://www.instagram.com/api/v1/web/likes/' + pk + '/unlike/', {\n method: 'POST', credentials: 'include',\n headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' },\n });\n if (!r3.ok) throw new Error('Failed to unlike: HTTP ' + r3.status);\n return [{ status: 'Unliked', user: username, post: caption || '(post #' + (idx+1) + ')' }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "instagram/unlike.yaml" + }, + { + "site": "instagram", + "name": "unsave", + "description": "Unsave (remove bookmark) an Instagram post", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Username of the post author" + }, + { + "name": "index", + "type": "int", + "default": 1, + "required": false, + "positional": false, + "help": "Post index (1 = most recent)" + } + ], + "columns": [ + "status", + "user", + "post" + ], + "pipeline": [ + { + "navigate": "https://www.instagram.com" + }, + { + "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const idx = ${{ args.index }} - 1;\n const headers = { 'X-IG-App-ID': '936619743392459' };\n const opts = { credentials: 'include', headers };\n\n const r1 = await fetch('https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), opts);\n if (!r1.ok) throw new Error('User not found: ' + username);\n const userId = (await r1.json())?.data?.user?.id;\n\n const r2 = await fetch('https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + (idx + 1), opts);\n const posts = (await r2.json())?.items || [];\n if (idx >= posts.length) throw new Error('Post index ' + (idx + 1) + ' not found');\n const pk = posts[idx].pk;\n const caption = (posts[idx].caption?.text || '').substring(0, 60);\n\n const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';\n const r3 = await fetch('https://www.instagram.com/api/v1/web/save/' + pk + '/unsave/', {\n method: 'POST', credentials: 'include',\n headers: { ...headers, 'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded' },\n });\n if (!r3.ok) throw new Error('Failed to unsave: HTTP ' + r3.status);\n return [{ status: 'Unsaved', user: username, post: caption || '(post #' + (idx+1) + ')' }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "instagram/unsave.yaml" + }, + { + "site": "instagram", + "name": "user", + "description": "Get recent posts from an Instagram user", + "domain": "www.instagram.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Instagram username" + }, + { + "name": "limit", + "type": "int", + "default": 12, + "required": false, + "positional": false, + "help": "Number of posts" + } + ], + "columns": [ + "index", + "caption", + "likes", + "comments", + "type", + "date" + ], + "pipeline": [ + { + "navigate": "https://www.instagram.com" + }, + { + "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const limit = ${{ args.limit }};\n const headers = { 'X-IG-App-ID': '936619743392459' };\n const opts = { credentials: 'include', headers };\n\n // Get user ID first\n const r1 = await fetch(\n 'https://www.instagram.com/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username),\n opts\n );\n if (!r1.ok) throw new Error('HTTP ' + r1.status + ' - make sure you are logged in to Instagram');\n const d1 = await r1.json();\n const userId = d1?.data?.user?.id;\n if (!userId) throw new Error('User not found: ' + username);\n\n // Get user feed\n const r2 = await fetch(\n 'https://www.instagram.com/api/v1/feed/user/' + userId + '/?count=' + limit,\n opts\n );\n if (!r2.ok) throw new Error('Failed to fetch user feed: HTTP ' + r2.status);\n const d2 = await r2.json();\n return (d2?.items || []).slice(0, limit).map((p, i) => ({\n index: i + 1,\n caption: (p.caption?.text || '').replace(/\\n/g, ' ').substring(0, 100),\n likes: p.like_count ?? 0,\n comments: p.comment_count ?? 0,\n type: p.media_type === 1 ? 'photo' : p.media_type === 2 ? 'video' : 'carousel',\n date: p.taken_at ? new Date(p.taken_at * 1000).toLocaleDateString() : '',\n }));\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "instagram/user.yaml" + }, + { + "site": "jike", + "name": "post", + "description": "即刻帖子详情及评论", + "domain": "m.okjike.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "id", + "type": "string", + "required": true, + "positional": true, + "help": "Post ID (from post URL)" + } + ], + "columns": [ + "type", + "author", + "content", + "likes", + "time" + ], + "pipeline": [ + { + "navigate": "https://m.okjike.com/originalPosts/${{ args.id }}" + }, + { + "evaluate": "(() => {\n try {\n const el = document.querySelector('script[type=\"application/json\"]');\n if (!el) return [];\n const data = JSON.parse(el.textContent);\n const pageProps = data?.props?.pageProps || {};\n const post = pageProps.post || {};\n const comments = pageProps.comments || [];\n\n const result = [{\n type: 'post',\n author: post.user?.screenName || '',\n content: post.content || '',\n likes: post.likeCount || 0,\n time: post.createdAt || '',\n }];\n\n for (const c of comments) {\n result.push({\n type: 'comment',\n author: c.user?.screenName || '',\n content: (c.content || '').replace(/\\n/g, ' '),\n likes: c.likeCount || 0,\n time: c.createdAt || '',\n });\n }\n\n return result;\n } catch (e) {\n return [];\n }\n})()\n" + }, + { + "map": { + "type": "${{ item.type }}", + "author": "${{ item.author }}", + "content": "${{ item.content }}", + "likes": "${{ item.likes }}", + "time": "${{ item.time }}" + } + } + ], + "type": "yaml", + "sourceFile": "jike/post.yaml" + }, + { + "site": "jike", + "name": "topic", + "description": "即刻话题/圈子帖子", + "domain": "m.okjike.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "id", + "type": "string", + "required": true, + "positional": true, + "help": "Topic ID (from topic URL, e.g. 553870e8e4b0cafb0a1bef68)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of posts" + } + ], + "columns": [ + "content", + "author", + "likes", + "comments", + "time", + "url" + ], + "pipeline": [ + { + "navigate": "https://m.okjike.com/topics/${{ args.id }}" + }, + { + "evaluate": "(() => {\n try {\n const el = document.querySelector('script[type=\"application/json\"]');\n if (!el) return [];\n const data = JSON.parse(el.textContent);\n const pageProps = data?.props?.pageProps || {};\n const posts = pageProps.posts || [];\n return posts.map(p => ({\n content: (p.content || '').replace(/\\n/g, ' ').slice(0, 80),\n author: p.user?.screenName || '',\n likes: p.likeCount || 0,\n comments: p.commentCount || 0,\n time: p.actionTime || p.createdAt || '',\n id: p.id || '',\n }));\n } catch (e) {\n return [];\n }\n})()\n" + }, + { + "map": { + "content": "${{ item.content }}", + "author": "${{ item.author }}", + "likes": "${{ item.likes }}", + "comments": "${{ item.comments }}", + "time": "${{ item.time }}", + "url": "https://web.okjike.com/originalPost/${{ item.id }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "jike/topic.yaml" + }, + { + "site": "jike", + "name": "user", + "description": "即刻用户动态", + "domain": "m.okjike.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "string", + "required": true, + "positional": true, + "help": "Username from profile URL (e.g. wenhao1996)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of posts" + } + ], + "columns": [ + "content", + "type", + "likes", + "comments", + "time", + "url" + ], + "pipeline": [ + { + "navigate": "https://m.okjike.com/users/${{ args.username }}" + }, + { + "evaluate": "(() => {\n try {\n const el = document.querySelector('script[type=\"application/json\"]');\n if (!el) return [];\n const data = JSON.parse(el.textContent);\n const posts = data?.props?.pageProps?.posts || [];\n return posts.map(p => ({\n content: (p.content || '').replace(/\\n/g, ' ').slice(0, 80),\n type: p.type === 'ORIGINAL_POST' ? 'post' : p.type === 'REPOST' ? 'repost' : p.type || '',\n likes: p.likeCount || 0,\n comments: p.commentCount || 0,\n time: p.actionTime || p.createdAt || '',\n id: p.id || '',\n }));\n } catch (e) {\n return [];\n }\n})()\n" + }, + { + "map": { + "content": "${{ item.content }}", + "type": "${{ item.type }}", + "likes": "${{ item.likes }}", + "comments": "${{ item.comments }}", + "time": "${{ item.time }}", + "url": "https://web.okjike.com/originalPost/${{ item.id }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "jike/user.yaml" + }, + { + "site": "jimeng", + "name": "generate", + "description": "即梦AI 文生图 — 输入 prompt 生成图片", + "domain": "jimeng.jianying.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "prompt", + "type": "string", + "required": true, + "positional": true, + "help": "图片描述 prompt" + }, + { + "name": "model", + "type": "string", + "default": "high_aes_general_v50", + "required": false, + "positional": false, + "help": "模型: high_aes_general_v50 (5.0 Lite), high_aes_general_v42 (4.6), high_aes_general_v40 (4.0)" + }, + { + "name": "wait", + "type": "int", + "default": 40, + "required": false, + "positional": false, + "help": "等待生成完成的秒数" + } + ], + "columns": [ + "status", + "prompt", + "image_count", + "image_urls" + ], + "pipeline": [ + { + "navigate": "https://jimeng.jianying.com/ai-tool/generate?type=image&workspace=0" + }, + { + "wait": 3 + }, + { + "evaluate": "(async () => {\n const prompt = ${{ args.prompt | json }};\n const waitSec = ${{ args.wait }};\n \n // Step 1: Count existing images before generation\n const beforeImgs = document.querySelectorAll('img[src*=\"dreamina-sign\"], img[src*=\"tb4s082cfz\"]').length;\n \n // Step 2: Clear and set prompt\n const editors = document.querySelectorAll('[contenteditable=\"true\"]');\n const editor = editors[0];\n if (!editor) return [{ status: 'failed', prompt: prompt, image_count: 0, image_urls: 'Editor not found' }];\n \n editor.focus();\n await new Promise(r => setTimeout(r, 200));\n document.execCommand('selectAll');\n await new Promise(r => setTimeout(r, 100));\n document.execCommand('delete');\n await new Promise(r => setTimeout(r, 200));\n document.execCommand('insertText', false, prompt);\n await new Promise(r => setTimeout(r, 500));\n \n // Step 3: Click generate\n const btn = document.querySelector('.lv-btn.lv-btn-primary[class*=\"circle\"]');\n if (!btn) return [{ status: 'failed', prompt: prompt, image_count: 0, image_urls: 'Generate button not found' }];\n btn.click();\n \n // Step 4: Wait for new images to appear\n let newImgs = [];\n for (let i = 0; i < waitSec; i++) {\n await new Promise(r => setTimeout(r, 1000));\n const allImgs = document.querySelectorAll('img[src*=\"dreamina-sign\"], img[src*=\"tb4s082cfz\"]');\n if (allImgs.length > beforeImgs) {\n // New images appeared — generation complete\n newImgs = Array.from(allImgs).slice(0, allImgs.length - beforeImgs);\n break;\n }\n }\n \n if (newImgs.length === 0) {\n return [{ status: 'timeout', prompt: prompt, image_count: 0, image_urls: 'Generation may still be in progress' }];\n }\n \n // Step 5: Extract image URLs (use thumbnail URLs which are accessible)\n const urls = newImgs.map(img => img.src);\n \n return [{ \n status: 'success', \n prompt: prompt.substring(0, 80), \n image_count: urls.length, \n image_urls: urls.join('\\n')\n }];\n})()\n" + }, + { + "map": { + "status": "${{ item.status }}", + "prompt": "${{ item.prompt }}", + "image_count": "${{ item.image_count }}", + "image_urls": "${{ item.image_urls }}" + } + } + ], + "type": "yaml", + "sourceFile": "jimeng/generate.yaml" + }, + { + "site": "jimeng", + "name": "history", + "description": "即梦AI 查看最近生成的作品", + "domain": "jimeng.jianying.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 5, + "required": false, + "positional": false, + "help": "" + } + ], + "columns": [ + "prompt", + "model", + "status", + "image_url", + "created_at" + ], + "pipeline": [ + { + "navigate": "https://jimeng.jianying.com/ai-tool/generate?type=image&workspace=0" + }, + { + "evaluate": "(async () => {\n const limit = ${{ args.limit }};\n const res = await fetch('/mweb/v1/get_history?aid=513695&device_platform=web®ion=cn&da_version=3.3.11&web_version=7.5.0&aigc_features=app_lip_sync', {\n method: 'POST',\n credentials: 'include',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ cursor: '', count: limit, need_page_item: true, need_aigc_data: true, aigc_mode_list: ['workbench'] })\n });\n const data = await res.json();\n const items = data?.data?.history_list || [];\n return items.slice(0, limit).map(item => {\n const params = item.aigc_image_params?.text2image_params || {};\n const images = item.image?.large_images || [];\n return {\n prompt: params.prompt || item.common_attr?.title || 'N/A',\n model: params.model_config?.model_name || 'unknown',\n status: item.common_attr?.status === 102 ? 'completed' : 'pending',\n image_url: images[0]?.image_url || '',\n created_at: new Date((item.common_attr?.create_time || 0) * 1000).toLocaleString('zh-CN'),\n };\n });\n})()\n" + }, + { + "map": { + "prompt": "${{ item.prompt }}", + "model": "${{ item.model }}", + "status": "${{ item.status }}", + "image_url": "${{ item.image_url }}", + "created_at": "${{ item.created_at }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "jimeng/history.yaml" + }, + { + "site": "linux-do", + "name": "categories", + "description": "linux.do 分类列表", + "domain": "linux.do", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "subcategories", + "type": "boolean", + "default": false, + "required": false, + "positional": false, + "help": "Include subcategories" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of categories" + } + ], + "columns": [ + "name", + "slug", + "id", + "topics", + "description" + ], + "pipeline": [ + { + "navigate": "https://linux.do" + }, + { + "evaluate": "(async () => {\n const res = await fetch('/categories.json', { credentials: 'include' });\n if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');\n let data;\n try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }\n const cats = data?.category_list?.categories || [];\n const showSub = ${{ args.subcategories }};\n const results = [];\n const limit = ${{ args.limit }};\n for (const c of cats.slice(0, ${{ args.limit }})) {\n results.push({\n name: c.name,\n slug: c.slug,\n id: c.id,\n topics: c.topic_count,\n description: (c.description_text || '').slice(0, 80),\n });\n if (results.length >= limit) break;\n if (showSub && c.subcategory_ids && c.subcategory_ids.length > 0) {\n const subRes = await fetch('/categories.json?parent_category_id=' + c.id, { credentials: 'include' });\n if (subRes.ok) {\n let subData;\n try { subData = await subRes.json(); } catch { continue; }\n const subCats = subData?.category_list?.categories || [];\n for (const sc of subCats) {\n results.push({\n name: c.name + ' / ' + sc.name,\n slug: sc.slug,\n id: sc.id,\n topics: sc.topic_count,\n description: (sc.description_text || '').slice(0, 80),\n });\n if (results.length >= limit) break;\n }\n }\n }\n if (results.length >= limit) break;\n }\n return results;\n})()\n" + }, + { + "map": { + "name": "${{ item.name }}", + "slug": "${{ item.slug }}", + "id": "${{ item.id }}", + "topics": "${{ item.topics }}", + "description": "${{ item.description }}" + } + } + ], + "type": "yaml", + "sourceFile": "linux-do/categories.yaml" + }, + { + "site": "linux-do", + "name": "search", + "description": "搜索 linux.do", + "domain": "linux.do", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of results" + } + ], + "columns": [ + "rank", + "title", + "views", + "likes", + "replies", + "url" + ], + "pipeline": [ + { + "navigate": "https://linux.do" + }, + { + "evaluate": "(async () => {\n const keyword = ${{ args.query | json }};\n const res = await fetch('/search.json?q=' + encodeURIComponent(keyword), { credentials: 'include' });\n if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');\n let data;\n try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }\n const topics = data?.topics || [];\n return topics.slice(0, ${{ args.limit }}).map(t => ({\n title: t.title,\n views: t.views,\n likes: t.like_count,\n replies: (t.posts_count || 1) - 1,\n url: 'https://linux.do/t/topic/' + t.id,\n }));\n})()\n" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "views": "${{ item.views }}", + "likes": "${{ item.likes }}", + "replies": "${{ item.replies }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "linux-do/search.yaml" + }, + { + "site": "linux-do", + "name": "tags", + "description": "linux.do 标签列表", + "domain": "linux.do", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 30, + "required": false, + "positional": false, + "help": "Number of tags" + } + ], + "columns": [ + "rank", + "name", + "count", + "url" + ], + "pipeline": [ + { + "navigate": "https://linux.do" + }, + { + "evaluate": "(async () => {\n const res = await fetch('/tags.json', { credentials: 'include' });\n if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');\n let data;\n try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }\n let tags = data?.tags || [];\n tags.sort((a, b) => (b.count || 0) - (a.count || 0));\n return tags.slice(0, ${{ args.limit }}).map(t => ({\n id: t.id,\n name: t.name || t.id,\n slug: t.slug,\n count: t.count || 0,\n }));\n})()\n" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "name": "${{ item.name }}", + "count": "${{ item.count }}", + "slug": "${{ item.slug }}", + "id": "${{ item.id }}", + "url": "https://linux.do/tag/${{ item.slug }}" + } + } + ], + "type": "yaml", + "sourceFile": "linux-do/tags.yaml" + }, + { + "site": "linux-do", + "name": "topic", + "description": "linux.do 帖子首页摘要和回复(首屏)", + "domain": "linux.do", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "id", + "type": "int", + "required": true, + "positional": true, + "help": "Topic ID" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of posts" + } + ], + "columns": [ + "author", + "content", + "likes", + "created_at" + ], + "pipeline": [ + { + "navigate": "https://linux.do" + }, + { + "evaluate": "(async () => {\n const toLocalTime = (utcStr) => {\n if (!utcStr) return '';\n const date = new Date(utcStr);\n return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();\n };\n const res = await fetch('/t/${{ args.id }}.json', { credentials: 'include' });\n if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');\n let data;\n try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }\n const strip = (html) => (html || '')\n .replace(//gi, ' ')\n .replace(/<\\/(p|div|li|blockquote|h[1-6])>/gi, ' ')\n .replace(/<[^>]+>/g, '')\n .replace(/ /g, ' ')\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/"/g, '\"')\n .replace(/&#(?:(\\d+)|x([0-9a-fA-F]+));/g, (_, dec, hex) => {\n try { return String.fromCodePoint(dec !== undefined ? Number(dec) : parseInt(hex, 16)); } catch { return ''; }\n })\n .replace(/\\s+/g, ' ')\n .trim();\n const posts = data?.post_stream?.posts || [];\n return posts.slice(0, ${{ args.limit }}).map(p => ({\n author: p.username,\n content: strip(p.cooked).slice(0, 200),\n likes: p.like_count,\n created_at: toLocalTime(p.created_at),\n }));\n})()\n" + }, + { + "map": { + "author": "${{ item.author }}", + "content": "${{ item.content }}", + "likes": "${{ item.likes }}", + "created_at": "${{ item.created_at }}" + } + } + ], + "type": "yaml", + "sourceFile": "linux-do/topic.yaml" + }, + { + "site": "linux-do", + "name": "user-posts", + "description": "linux.do 用户的帖子", + "domain": "linux.do", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Username" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of posts" + } + ], + "columns": [ + "index", + "topic_user", + "topic", + "reply", + "time", + "url" + ], + "pipeline": [ + { + "navigate": "https://linux.do" + }, + { + "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const toLocalTime = (utcStr) => {\n if (!utcStr) return '';\n const date = new Date(utcStr);\n return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();\n };\n const strip = (html) => (html || '')\n .replace(//gi, ' ')\n .replace(/<\\/(p|div|li|blockquote|h[1-6])>/gi, ' ')\n .replace(/<[^>]+>/g, '')\n .replace(/ /g, ' ')\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/"/g, '\"')\n .replace(/&#(?:(\\d+)|x([0-9a-fA-F]+));/g, (_, dec, hex) => {\n try { return String.fromCodePoint(dec !== undefined ? Number(dec) : parseInt(hex, 16)); } catch { return ''; }\n })\n .replace(/\\s+/g, ' ')\n .trim();\n const limit = ${{ args.limit | default(20) }};\n const res = await fetch('/user_actions.json?username=' + encodeURIComponent(username) + '&filter=5&offset=0&limit=' + limit, { credentials: 'include' });\n if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');\n let data;\n try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }\n const actions = data?.user_actions || [];\n return actions.slice(0, limit).map(a => ({\n author: a.acting_username || a.username || '',\n title: a.title || '',\n content: strip(a.excerpt).slice(0, 200),\n created_at: toLocalTime(a.created_at),\n url: 'https://linux.do/t/topic/' + a.topic_id + '/' + a.post_number,\n }));\n})()\n" + }, + { + "map": { + "index": "${{ index + 1 }}", + "topic_user": "${{ item.author }}", + "topic": "${{ item.title }}", + "reply": "${{ item.content }}", + "time": "${{ item.created_at }}", + "url": "${{ item.url }}" + } + } + ], + "type": "yaml", + "sourceFile": "linux-do/user-posts.yaml" + }, + { + "site": "linux-do", + "name": "user-topics", + "description": "linux.do 用户创建的话题", + "domain": "linux.do", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Username" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of topics" + } + ], + "columns": [ + "rank", + "title", + "replies", + "created_at", + "likes", + "views", + "url" + ], + "pipeline": [ + { + "navigate": "https://linux.do" + }, + { + "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const toLocalTime = (utcStr) => {\n if (!utcStr) return '';\n const date = new Date(utcStr);\n return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();\n };\n const res = await fetch('/topics/created-by/' + encodeURIComponent(username) + '.json', { credentials: 'include' });\n if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');\n let data;\n try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }\n const topics = data?.topic_list?.topics || [];\n return topics.slice(0, ${{ args.limit }}).map(t => ({\n title: t.fancy_title || t.title || '',\n replies: t.posts_count || 0,\n created_at: toLocalTime(t.created_at),\n likes: t.like_count || 0,\n views: t.views || 0,\n url: 'https://linux.do/t/topic/' + t.id,\n }));\n})()\n" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "replies": "${{ item.replies }}", + "created_at": "${{ item.created_at }}", + "likes": "${{ item.likes }}", + "views": "${{ item.views }}", + "url": "${{ item.url }}" + } + } + ], + "type": "yaml", + "sourceFile": "linux-do/user-topics.yaml" + }, + { + "site": "lobsters", + "name": "active", + "description": "Lobste.rs most active discussions", + "domain": "lobste.rs", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of stories" + } + ], + "columns": [ + "rank", + "title", + "score", + "author", + "comments", + "tags" + ], + "pipeline": [ + { + "fetch": { + "url": "https://lobste.rs/active.json" + } + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "score": "${{ item.score }}", + "author": "${{ item.submitter_user }}", + "comments": "${{ item.comment_count }}", + "tags": "${{ item.tags | join(', ') }}", + "url": "${{ item.comments_url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "lobsters/active.yaml" + }, + { + "site": "lobsters", + "name": "hot", + "description": "Lobste.rs hottest stories", + "domain": "lobste.rs", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of stories" + } + ], + "columns": [ + "rank", + "title", + "score", + "author", + "comments", + "tags" + ], + "pipeline": [ + { + "fetch": { + "url": "https://lobste.rs/hottest.json" + } + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "score": "${{ item.score }}", + "author": "${{ item.submitter_user }}", + "comments": "${{ item.comment_count }}", + "tags": "${{ item.tags | join(', ') }}", + "url": "${{ item.comments_url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "lobsters/hot.yaml" + }, + { + "site": "lobsters", + "name": "newest", + "description": "Lobste.rs newest stories", + "domain": "lobste.rs", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of stories" + } + ], + "columns": [ + "rank", + "title", + "score", + "author", + "comments", + "tags" + ], + "pipeline": [ + { + "fetch": { + "url": "https://lobste.rs/newest.json" + } + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "score": "${{ item.score }}", + "author": "${{ item.submitter_user }}", + "comments": "${{ item.comment_count }}", + "tags": "${{ item.tags | join(', ') }}", + "url": "${{ item.comments_url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "lobsters/newest.yaml" + }, + { + "site": "lobsters", + "name": "tag", + "description": "Lobste.rs stories by tag", + "domain": "lobste.rs", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "tag", + "type": "str", + "required": true, + "positional": true, + "help": "Tag name (e.g. programming, rust, security, ai)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of stories" + } + ], + "columns": [ + "rank", + "title", + "score", + "author", + "comments", + "tags" + ], + "pipeline": [ + { + "fetch": { + "url": "https://lobste.rs/t/${{ args.tag }}.json" + } + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "score": "${{ item.score }}", + "author": "${{ item.submitter_user }}", + "comments": "${{ item.comment_count }}", + "tags": "${{ item.tags | join(', ') }}", + "url": "${{ item.comments_url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "lobsters/tag.yaml" + }, + { + "site": "pixiv", + "name": "detail", + "description": "View illustration details (tags, stats, URLs)", + "domain": "www.pixiv.net", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "id", + "type": "str", + "required": true, + "positional": true, + "help": "Illustration ID" + } + ], + "columns": [ + "illust_id", + "title", + "author", + "type", + "pages", + "bookmarks", + "likes", + "views", + "tags", + "created", + "url" + ], + "pipeline": [ + { + "navigate": "https://www.pixiv.net" + }, + { + "evaluate": "(async () => {\n const id = ${{ args.id | json }};\n const res = await fetch(\n 'https://www.pixiv.net/ajax/illust/' + id,\n { credentials: 'include' }\n );\n if (!res.ok) {\n if (res.status === 401 || res.status === 403) throw new Error('Authentication required — please log in to Pixiv in Chrome');\n if (res.status === 404) throw new Error('Illustration not found: ' + id);\n throw new Error('Pixiv request failed (HTTP ' + res.status + ')');\n }\n const data = await res.json();\n const b = data?.body;\n if (!b) throw new Error('Illustration not found');\n return [{\n illust_id: b.illustId,\n title: b.illustTitle,\n author: b.userName,\n user_id: b.userId,\n type: b.illustType === 0 ? 'illust' : b.illustType === 1 ? 'manga' : b.illustType === 2 ? 'ugoira' : String(b.illustType),\n pages: b.pageCount,\n bookmarks: b.bookmarkCount,\n likes: b.likeCount,\n views: b.viewCount,\n tags: (b.tags?.tags || []).map(t => t.tag).join(', '),\n created: b.createDate?.split('T')[0] || '',\n url: 'https://www.pixiv.net/artworks/' + b.illustId\n }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "pixiv/detail.yaml" + }, + { + "site": "pixiv", + "name": "ranking", + "description": "Pixiv illustration rankings (daily/weekly/monthly)", + "domain": "www.pixiv.net", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "mode", + "type": "str", + "default": "daily", + "required": false, + "positional": false, + "help": "Ranking mode", + "choices": [ + "daily", + "weekly", + "monthly", + "rookie", + "original", + "male", + "female", + "daily_r18", + "weekly_r18" + ] + }, + { + "name": "page", + "type": "int", + "default": 1, + "required": false, + "positional": false, + "help": "Page number" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of results" + } + ], + "columns": [ + "rank", + "title", + "author", + "illust_id", + "pages", + "bookmarks" + ], + "pipeline": [ + { + "navigate": "https://www.pixiv.net" + }, + { + "evaluate": "(async () => {\n const mode = ${{ args.mode | json }};\n const page = ${{ args.page | json }};\n const limit = ${{ args.limit | json }};\n const res = await fetch(\n 'https://www.pixiv.net/ranking.php?mode=' + mode + '&p=' + page + '&format=json',\n { credentials: 'include' }\n );\n if (!res.ok) {\n if (res.status === 401 || res.status === 403) throw new Error('Authentication required — please log in to Pixiv in Chrome');\n throw new Error('Pixiv request failed (HTTP ' + res.status + ')');\n }\n const data = await res.json();\n const items = (data?.contents || []).slice(0, limit);\n return items.map((item, i) => ({\n rank: item.rank,\n title: item.title,\n author: item.user_name,\n user_id: item.user_id,\n illust_id: item.illust_id,\n pages: item.illust_page_count,\n bookmarks: item.illust_bookmark_count,\n url: 'https://www.pixiv.net/artworks/' + item.illust_id\n }));\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "pixiv/ranking.yaml" + }, + { + "site": "pixiv", + "name": "user", + "description": "View Pixiv artist profile", + "domain": "www.pixiv.net", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "uid", + "type": "str", + "required": true, + "positional": true, + "help": "Pixiv user ID" + } + ], + "columns": [ + "user_id", + "name", + "premium", + "following", + "illusts", + "manga", + "novels", + "comment" + ], + "pipeline": [ + { + "navigate": "https://www.pixiv.net" + }, + { + "evaluate": "(async () => {\n const uid = ${{ args.uid | json }};\n const res = await fetch(\n 'https://www.pixiv.net/ajax/user/' + uid + '?full=1',\n { credentials: 'include' }\n );\n if (!res.ok) {\n if (res.status === 401 || res.status === 403) throw new Error('Authentication required — please log in to Pixiv in Chrome');\n if (res.status === 404) throw new Error('User not found: ' + uid);\n throw new Error('Pixiv request failed (HTTP ' + res.status + ')');\n }\n const data = await res.json();\n const b = data?.body;\n if (!b) throw new Error('User not found');\n return [{\n user_id: uid,\n name: b.name,\n premium: b.premium ? 'Yes' : 'No',\n following: b.following,\n illusts: typeof b.illusts === 'object' ? Object.keys(b.illusts).length : (b.illusts || 0),\n manga: typeof b.manga === 'object' ? Object.keys(b.manga).length : (b.manga || 0),\n novels: typeof b.novels === 'object' ? Object.keys(b.novels).length : (b.novels || 0),\n comment: (b.comment || '').slice(0, 80),\n url: 'https://www.pixiv.net/users/' + uid\n }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "pixiv/user.yaml" + }, + { + "site": "reddit", + "name": "frontpage", + "description": "Reddit Frontpage / r/all", + "domain": "reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 15, + "required": false, + "positional": false, + "help": "" + } + ], + "columns": [ + "title", + "subreddit", + "author", + "upvotes", + "comments", + "url" + ], + "pipeline": [ + { + "navigate": "https://www.reddit.com" + }, + { + "evaluate": "(async () => {\n const res = await fetch('/r/all.json?limit=${{ args.limit }}', { credentials: 'include' });\n const j = await res.json();\n return j?.data?.children || [];\n})()\n" + }, + { + "map": { + "title": "${{ item.data.title }}", + "subreddit": "${{ item.data.subreddit_name_prefixed }}", + "author": "${{ item.data.author }}", + "upvotes": "${{ item.data.score }}", + "comments": "${{ item.data.num_comments }}", + "url": "https://www.reddit.com${{ item.data.permalink }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "reddit/frontpage.yaml" + }, + { + "site": "reddit", + "name": "hot", + "description": "Reddit 热门帖子", + "domain": "www.reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "subreddit", + "type": "str", + "default": "", + "required": false, + "positional": false, + "help": "Subreddit name (e.g. programming). Empty for frontpage" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of posts" + } + ], + "columns": [ + "rank", + "title", + "subreddit", + "score", + "comments" + ], + "pipeline": [ + { + "navigate": "https://www.reddit.com" + }, + { + "evaluate": "(async () => {\n const sub = ${{ args.subreddit | json }};\n const path = sub ? '/r/' + sub + '/hot.json' : '/hot.json';\n const limit = ${{ args.limit }};\n const res = await fetch(path + '?limit=' + limit + '&raw_json=1', {\n credentials: 'include'\n });\n const d = await res.json();\n return (d?.data?.children || []).map(c => ({\n title: c.data.title,\n subreddit: c.data.subreddit_name_prefixed,\n score: c.data.score,\n comments: c.data.num_comments,\n author: c.data.author,\n url: 'https://www.reddit.com' + c.data.permalink,\n }));\n})()\n" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "subreddit": "${{ item.subreddit }}", + "score": "${{ item.score }}", + "comments": "${{ item.comments }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "reddit/hot.yaml" + }, + { + "site": "reddit", + "name": "popular", + "description": "Reddit Popular posts (/r/popular)", + "domain": "reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "" + } + ], + "columns": [ + "rank", + "title", + "subreddit", + "score", + "comments", + "url" + ], + "pipeline": [ + { + "navigate": "https://www.reddit.com" + }, + { + "evaluate": "(async () => {\n const limit = ${{ args.limit }};\n const res = await fetch('/r/popular.json?limit=' + limit + '&raw_json=1', {\n credentials: 'include'\n });\n const d = await res.json();\n return (d?.data?.children || []).map(c => ({\n title: c.data.title,\n subreddit: c.data.subreddit_name_prefixed,\n score: c.data.score,\n comments: c.data.num_comments,\n author: c.data.author,\n url: 'https://www.reddit.com' + c.data.permalink,\n }));\n})()\n" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "subreddit": "${{ item.subreddit }}", + "score": "${{ item.score }}", + "comments": "${{ item.comments }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "reddit/popular.yaml" + }, + { + "site": "reddit", + "name": "search", + "description": "Search Reddit Posts", + "domain": "reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "string", + "required": true, + "positional": true, + "help": "" + }, + { + "name": "subreddit", + "type": "string", + "default": "", + "required": false, + "positional": false, + "help": "Search within a specific subreddit" + }, + { + "name": "sort", + "type": "string", + "default": "relevance", + "required": false, + "positional": false, + "help": "Sort order: relevance, hot, top, new, comments" + }, + { + "name": "time", + "type": "string", + "default": "all", + "required": false, + "positional": false, + "help": "Time filter: hour, day, week, month, year, all" + }, + { + "name": "limit", + "type": "int", + "default": 15, + "required": false, + "positional": false, + "help": "" + } + ], + "columns": [ + "title", + "subreddit", + "author", + "score", + "comments", + "url" + ], + "pipeline": [ + { + "navigate": "https://www.reddit.com" + }, + { + "evaluate": "(async () => {\n const q = encodeURIComponent(${{ args.query | json }});\n const sub = ${{ args.subreddit | json }};\n const sort = ${{ args.sort | json }};\n const time = ${{ args.time | json }};\n const limit = ${{ args.limit }};\n const basePath = sub ? '/r/' + sub + '/search.json' : '/search.json';\n const params = 'q=' + q + '&sort=' + sort + '&t=' + time + '&limit=' + limit\n + '&restrict_sr=' + (sub ? 'on' : 'off') + '&raw_json=1';\n const res = await fetch(basePath + '?' + params, { credentials: 'include' });\n const d = await res.json();\n return (d?.data?.children || []).map(c => ({\n title: c.data.title,\n subreddit: c.data.subreddit_name_prefixed,\n author: c.data.author,\n score: c.data.score,\n comments: c.data.num_comments,\n url: 'https://www.reddit.com' + c.data.permalink,\n }));\n})()\n" + }, + { + "map": { + "title": "${{ item.title }}", + "subreddit": "${{ item.subreddit }}", + "author": "${{ item.author }}", + "score": "${{ item.score }}", + "comments": "${{ item.comments }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "reddit/search.yaml" + }, + { + "site": "reddit", + "name": "subreddit", + "description": "Get posts from a specific Subreddit", + "domain": "reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "name", + "type": "string", + "required": true, + "positional": true, + "help": "" + }, + { + "name": "sort", + "type": "string", + "default": "hot", + "required": false, + "positional": false, + "help": "Sorting method: hot, new, top, rising, controversial" + }, + { + "name": "time", + "type": "string", + "default": "all", + "required": false, + "positional": false, + "help": "Time filter for top/controversial: hour, day, week, month, year, all" + }, + { + "name": "limit", + "type": "int", + "default": 15, + "required": false, + "positional": false, + "help": "" + } + ], + "columns": [ + "title", + "author", + "upvotes", + "comments", + "url" + ], + "pipeline": [ + { + "navigate": "https://www.reddit.com" + }, + { + "evaluate": "(async () => {\n let sub = ${{ args.name | json }};\n if (sub.startsWith('r/')) sub = sub.slice(2);\n const sort = ${{ args.sort | json }};\n const time = ${{ args.time | json }};\n const limit = ${{ args.limit }};\n let url = '/r/' + sub + '/' + sort + '.json?limit=' + limit + '&raw_json=1';\n if ((sort === 'top' || sort === 'controversial') && time) {\n url += '&t=' + time;\n }\n const res = await fetch(url, { credentials: 'include' });\n const j = await res.json();\n return j?.data?.children || [];\n})()\n" + }, + { + "map": { + "title": "${{ item.data.title }}", + "author": "${{ item.data.author }}", + "upvotes": "${{ item.data.score }}", + "comments": "${{ item.data.num_comments }}", + "url": "https://www.reddit.com${{ item.data.permalink }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "reddit/subreddit.yaml" + }, + { + "site": "reddit", + "name": "user", + "description": "View a Reddit user profile", + "domain": "reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "string", + "required": true, + "positional": true, + "help": "" + } + ], + "columns": [ + "field", + "value" + ], + "pipeline": [ + { + "navigate": "https://www.reddit.com" + }, + { + "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const name = username.startsWith('u/') ? username.slice(2) : username;\n const res = await fetch('/user/' + name + '/about.json?raw_json=1', {\n credentials: 'include'\n });\n const d = await res.json();\n const u = d?.data || d || {};\n const created = u.created_utc ? new Date(u.created_utc * 1000).toISOString().split('T')[0] : '-';\n return [\n { field: 'Username', value: 'u/' + (u.name || name) },\n { field: 'Post Karma', value: String(u.link_karma || 0) },\n { field: 'Comment Karma', value: String(u.comment_karma || 0) },\n { field: 'Total Karma', value: String(u.total_karma || (u.link_karma||0) + (u.comment_karma||0)) },\n { field: 'Account Created', value: created },\n { field: 'Gold', value: u.is_gold ? '⭐ Yes' : 'No' },\n { field: 'Verified', value: u.verified ? '✅ Yes' : 'No' },\n ];\n})()\n" + }, + { + "map": { + "field": "${{ item.field }}", + "value": "${{ item.value }}" + } + } + ], + "type": "yaml", + "sourceFile": "reddit/user.yaml" + }, + { + "site": "reddit", + "name": "user-comments", + "description": "View a Reddit user's comment history", + "domain": "reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "string", + "required": true, + "positional": true, + "help": "" + }, + { + "name": "limit", + "type": "int", + "default": 15, + "required": false, + "positional": false, + "help": "" + } + ], + "columns": [ + "subreddit", + "score", + "body", + "url" + ], + "pipeline": [ + { + "navigate": "https://www.reddit.com" + }, + { + "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const name = username.startsWith('u/') ? username.slice(2) : username;\n const limit = ${{ args.limit }};\n const res = await fetch('/user/' + name + '/comments.json?limit=' + limit + '&raw_json=1', {\n credentials: 'include'\n });\n const d = await res.json();\n return (d?.data?.children || []).map(c => {\n let body = c.data.body || '';\n if (body.length > 300) body = body.slice(0, 300) + '...';\n return {\n subreddit: c.data.subreddit_name_prefixed,\n score: c.data.score,\n body: body,\n url: 'https://www.reddit.com' + c.data.permalink,\n };\n });\n})()\n" + }, + { + "map": { + "subreddit": "${{ item.subreddit }}", + "score": "${{ item.score }}", + "body": "${{ item.body }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "reddit/user-comments.yaml" + }, + { + "site": "reddit", + "name": "user-posts", + "description": "View a Reddit user's submitted posts", + "domain": "reddit.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "string", + "required": true, + "positional": true, + "help": "" + }, + { + "name": "limit", + "type": "int", + "default": 15, + "required": false, + "positional": false, + "help": "" + } + ], + "columns": [ + "title", + "subreddit", + "score", + "comments", + "url" + ], + "pipeline": [ + { + "navigate": "https://www.reddit.com" + }, + { + "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const name = username.startsWith('u/') ? username.slice(2) : username;\n const limit = ${{ args.limit }};\n const res = await fetch('/user/' + name + '/submitted.json?limit=' + limit + '&raw_json=1', {\n credentials: 'include'\n });\n const d = await res.json();\n return (d?.data?.children || []).map(c => ({\n title: c.data.title,\n subreddit: c.data.subreddit_name_prefixed,\n score: c.data.score,\n comments: c.data.num_comments,\n url: 'https://www.reddit.com' + c.data.permalink,\n }));\n})()\n" + }, + { + "map": { + "title": "${{ item.title }}", + "subreddit": "${{ item.subreddit }}", + "score": "${{ item.score }}", + "comments": "${{ item.comments }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "reddit/user-posts.yaml" + }, + { + "site": "stackoverflow", + "name": "bounties", + "description": "Active bounties on Stack Overflow", + "domain": "stackoverflow.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "positional": false, + "help": "Max number of results" + } + ], + "columns": [ + "bounty", + "title", + "score", + "answers", + "url" + ], + "pipeline": [ + { + "fetch": { + "url": "https://api.stackexchange.com/2.3/questions/featured?order=desc&sort=activity&site=stackoverflow" + } + }, + { + "select": "items" + }, + { + "map": { + "title": "${{ item.title }}", + "bounty": "${{ item.bounty_amount }}", + "score": "${{ item.score }}", + "answers": "${{ item.answer_count }}", + "url": "${{ item.link }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "stackoverflow/bounties.yaml" + }, + { + "site": "stackoverflow", + "name": "hot", + "description": "Hot Stack Overflow questions", + "domain": "stackoverflow.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "positional": false, + "help": "Max number of results" + } + ], + "columns": [ + "title", + "score", + "answers", + "url" + ], + "pipeline": [ + { + "fetch": { + "url": "https://api.stackexchange.com/2.3/questions?order=desc&sort=hot&site=stackoverflow" + } + }, + { + "select": "items" + }, + { + "map": { + "title": "${{ item.title }}", + "score": "${{ item.score }}", + "answers": "${{ item.answer_count }}", + "url": "${{ item.link }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "stackoverflow/hot.yaml" + }, + { + "site": "stackoverflow", + "name": "search", + "description": "Search Stack Overflow questions", + "domain": "stackoverflow.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "query", + "type": "string", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "positional": false, + "help": "Max number of results" + } + ], + "columns": [ + "title", + "score", + "answers", + "url" + ], + "pipeline": [ + { + "fetch": { + "url": "https://api.stackexchange.com/2.3/search/advanced?order=desc&sort=relevance&q=${{ args.query }}&site=stackoverflow" + } + }, + { + "select": "items" + }, + { + "map": { + "title": "${{ item.title }}", + "score": "${{ item.score }}", + "answers": "${{ item.answer_count }}", + "url": "${{ item.link }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "stackoverflow/search.yaml" + }, + { + "site": "stackoverflow", + "name": "unanswered", + "description": "Top voted unanswered questions on Stack Overflow", + "domain": "stackoverflow.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "positional": false, + "help": "Max number of results" + } + ], + "columns": [ + "title", + "score", + "answers", + "url" + ], + "pipeline": [ + { + "fetch": { + "url": "https://api.stackexchange.com/2.3/questions/unanswered?order=desc&sort=votes&site=stackoverflow" + } + }, + { + "select": "items" + }, + { + "map": { + "title": "${{ item.title }}", + "score": "${{ item.score }}", + "answers": "${{ item.answer_count }}", + "url": "${{ item.link }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "stackoverflow/unanswered.yaml" + }, + { + "site": "steam", + "name": "top-sellers", + "description": "Steam top selling games", + "domain": "store.steampowered.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "positional": false, + "help": "Number of games" + } + ], + "columns": [ + "rank", + "name", + "price", + "discount", + "url" + ], + "pipeline": [ + { + "fetch": { + "url": "https://store.steampowered.com/api/featuredcategories/" + } + }, + { + "select": "top_sellers.items" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "name": "${{ item.name }}", + "price": "${{ item.final_price }}", + "discount": "${{ item.discount_percent }}", + "url": "https://store.steampowered.com/app/${{ item.id }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "steam/top-sellers.yaml" + }, + { + "site": "tiktok", + "name": "comment", + "description": "Comment on a TikTok video", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "positional": true, + "help": "TikTok video URL" + }, + { + "name": "text", + "type": "str", + "required": true, + "positional": true, + "help": "Comment text" + } + ], + "columns": [ + "status", + "url", + "text" + ], + "pipeline": [ + { + "navigate": { + "url": "${{ args.url }}", + "settleMs": 6000 + } + }, + { + "evaluate": "(async () => {\n const url = ${{ args.url | json }};\n const commentText = ${{ args.text | json }};\n const wait = (ms) => new Promise(r => setTimeout(r, ms));\n\n // Click comment icon to expand comment section\n const commentIcon = document.querySelector('[data-e2e=\"comment-icon\"]');\n if (commentIcon) {\n const cBtn = commentIcon.closest('button') || commentIcon.closest('[role=\"button\"]') || commentIcon;\n cBtn.click();\n await wait(3000);\n }\n\n // Count existing comments for verification\n const beforeCount = document.querySelectorAll('[data-e2e=\"comment-level-1\"]').length;\n\n // Find comment input\n const input = document.querySelector('[data-e2e=\"comment-input\"] [contenteditable=\"true\"]') ||\n document.querySelector('[contenteditable=\"true\"]');\n if (!input) throw new Error('Comment input not found - make sure you are logged in');\n\n input.focus();\n document.execCommand('insertText', false, commentText);\n await wait(1000);\n\n // Click post button\n const btns = Array.from(document.querySelectorAll('[data-e2e=\"comment-post\"], button'));\n const postBtn = btns.find(function(b) {\n var t = b.textContent.trim();\n return t === 'Post' || t === '发布' || t === '发送';\n });\n if (!postBtn) throw new Error('Post button not found');\n postBtn.click();\n await wait(3000);\n\n // Verify comment was posted by checking if comment count increased\n const afterCount = document.querySelectorAll('[data-e2e=\"comment-level-1\"]').length;\n const posted = afterCount > beforeCount;\n\n return [{ status: posted ? 'Commented' : 'Comment may have failed', url: url, text: commentText }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "tiktok/comment.yaml" + }, + { + "site": "tiktok", + "name": "explore", + "description": "Get trending TikTok videos from explore page", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of videos" + } + ], + "columns": [ + "rank", + "author", + "views", + "url" + ], + "pipeline": [ + { + "navigate": { + "url": "https://www.tiktok.com/explore", + "settleMs": 5000 + } + }, + { + "evaluate": "(() => {\n const limit = ${{ args.limit }};\n const links = Array.from(document.querySelectorAll('a[href*=\"/video/\"]'));\n const seen = new Set();\n const results = [];\n for (const a of links) {\n const href = a.href;\n if (seen.has(href)) continue;\n seen.add(href);\n const match = href.match(/@([^/]+)\\/video\\/(\\d+)/);\n results.push({\n rank: results.length + 1,\n author: match ? match[1] : '',\n views: a.textContent.trim() || '-',\n url: href,\n });\n if (results.length >= limit) break;\n }\n return results;\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "tiktok/explore.yaml" + }, + { + "site": "tiktok", + "name": "follow", + "description": "Follow a TikTok user", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "TikTok username (without @)" + } + ], + "columns": [ + "status", + "username" + ], + "pipeline": [ + { + "navigate": { + "url": "https://www.tiktok.com/@${{ args.username }}", + "settleMs": 6000 + } + }, + { + "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const buttons = Array.from(document.querySelectorAll('button, [role=\"button\"]'));\n const followBtn = buttons.find(function(b) {\n var text = b.textContent.trim();\n return text === 'Follow' || text === '关注';\n });\n if (!followBtn) {\n var isFollowing = buttons.some(function(b) {\n var t = b.textContent.trim();\n return t === 'Following' || t === '已关注' || t === 'Friends' || t === '互关';\n });\n if (isFollowing) return [{ status: 'Already following', username: username }];\n return [{ status: 'Follow button not found', username: username }];\n }\n followBtn.click();\n await new Promise(r => setTimeout(r, 2000));\n return [{ status: 'Followed', username: username }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "tiktok/follow.yaml" + }, + { + "site": "tiktok", + "name": "following", + "description": "List accounts you follow on TikTok", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of accounts" + } + ], + "columns": [ + "index", + "username", + "name" + ], + "pipeline": [ + { + "navigate": { + "url": "https://www.tiktok.com/following", + "settleMs": 5000 + } + }, + { + "evaluate": "(() => {\n const limit = ${{ args.limit }};\n const links = Array.from(document.querySelectorAll('a[href*=\"/@\"]'))\n .filter(function(a) {\n const text = a.textContent.trim();\n return text.length > 1 && text.length < 80 &&\n !text.includes('Profile') && !text.includes('More') && !text.includes('Upload');\n });\n\n const seen = {};\n const results = [];\n for (const a of links) {\n const match = a.href.match(/@([^/]+)/);\n const username = match ? match[1] : '';\n if (!username || seen[username]) continue;\n seen[username] = true;\n const raw = a.textContent.trim();\n const name = raw.replace(username, '').replace('@', '').trim();\n results.push({\n index: results.length + 1,\n username: username,\n name: name || username,\n });\n if (results.length >= limit) break;\n }\n return results;\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "tiktok/following.yaml" + }, + { + "site": "tiktok", + "name": "friends", + "description": "Get TikTok friend suggestions", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of suggestions" + } + ], + "columns": [ + "index", + "username", + "name" + ], + "pipeline": [ + { + "navigate": { + "url": "https://www.tiktok.com/friends", + "settleMs": 5000 + } + }, + { + "evaluate": "(() => {\n const limit = ${{ args.limit }};\n const links = Array.from(document.querySelectorAll('a[href*=\"/@\"]'))\n .filter(function(a) {\n const text = a.textContent.trim();\n return text.length > 1 && text.length < 80 &&\n !text.includes('Profile') && !text.includes('More') && !text.includes('Upload');\n });\n\n const seen = {};\n const results = [];\n for (const a of links) {\n const match = a.href.match(/@([^/]+)/);\n const username = match ? match[1] : '';\n if (!username || seen[username]) continue;\n seen[username] = true;\n const raw = a.textContent.trim();\n const hasFollow = raw.includes('Follow');\n const name = raw.replace('Follow', '').replace(username, '').replace('@', '').trim();\n results.push({\n index: results.length + 1,\n username: username,\n name: name || username,\n });\n if (results.length >= limit) break;\n }\n return results;\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "tiktok/friends.yaml" + }, + { + "site": "tiktok", + "name": "like", + "description": "Like a TikTok video", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "positional": true, + "help": "TikTok video URL" + } + ], + "columns": [ + "status", + "likes", + "url" + ], + "pipeline": [ + { + "navigate": { + "url": "${{ args.url }}", + "settleMs": 6000 + } + }, + { + "evaluate": "(async () => {\n const url = ${{ args.url | json }};\n const btn = document.querySelector('[data-e2e=\"like-icon\"]');\n if (!btn) throw new Error('Like button not found - make sure you are logged in');\n const container = btn.closest('button') || btn.closest('[role=\"button\"]') || btn;\n const aria = (container.getAttribute('aria-label') || '').toLowerCase();\n const color = window.getComputedStyle(btn).color;\n const isLiked = aria.includes('unlike') || aria.includes('取消点赞') ||\n (color && (color.includes('255, 65') || color.includes('fe2c55')));\n if (isLiked) {\n const count = document.querySelector('[data-e2e=\"like-count\"]');\n return [{ status: 'Already liked', likes: count ? count.textContent.trim() : '-', url: url }];\n }\n container.click();\n await new Promise(r => setTimeout(r, 2000));\n const count = document.querySelector('[data-e2e=\"like-count\"]');\n return [{ status: 'Liked', likes: count ? count.textContent.trim() : '-', url: url }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "tiktok/like.yaml" + }, + { + "site": "tiktok", + "name": "live", + "description": "Browse live streams on TikTok", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "positional": false, + "help": "Number of streams" + } + ], + "columns": [ + "index", + "streamer", + "viewers", + "url" + ], + "pipeline": [ + { + "navigate": { + "url": "https://www.tiktok.com/live", + "settleMs": 5000 + } + }, + { + "evaluate": "(() => {\n const limit = ${{ args.limit }};\n // Sidebar live list has structured data\n const items = document.querySelectorAll('[data-e2e=\"live-side-nav-item\"]');\n const sidebar = Array.from(items).slice(0, limit).map(function(el, i) {\n const nameEl = el.querySelector('[data-e2e=\"live-side-nav-name\"]');\n const countEl = el.querySelector('[data-e2e=\"person-count\"]');\n const link = el.querySelector('a');\n return {\n index: i + 1,\n streamer: nameEl ? nameEl.textContent.trim() : '',\n viewers: countEl ? countEl.textContent.trim() : '-',\n url: link ? link.href : '',\n };\n });\n\n if (sidebar.length > 0) return sidebar;\n\n // Fallback: main content cards\n const cards = document.querySelectorAll('[data-e2e=\"discover-list-live-card\"]');\n return Array.from(cards).slice(0, limit).map(function(card, i) {\n const text = card.textContent.trim().replace(/\\s+/g, ' ');\n const link = card.querySelector('a[href*=\"/live\"]');\n const viewerMatch = text.match(/(\\d[\\d,.]*)\\s*watching/);\n return {\n index: i + 1,\n streamer: text.replace(/LIVE.*$/, '').trim().substring(0, 40),\n viewers: viewerMatch ? viewerMatch[1] : '-',\n url: link ? link.href : '',\n };\n });\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "tiktok/live.yaml" + }, + { + "site": "tiktok", + "name": "notifications", + "description": "Get TikTok notifications (likes, comments, mentions, followers)", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 15, + "required": false, + "positional": false, + "help": "Number of notifications" + }, + { + "name": "type", + "type": "str", + "default": "all", + "required": false, + "positional": false, + "help": "Notification type", + "choices": [ + "all", + "likes", + "comments", + "mentions", + "followers" + ] + } + ], + "columns": [ + "index", + "text" + ], + "pipeline": [ + { + "navigate": { + "url": "https://www.tiktok.com/following", + "settleMs": 5000 + } + }, + { + "evaluate": "(async () => {\n const limit = ${{ args.limit }};\n const type = ${{ args.type | json }};\n const wait = (ms) => new Promise(r => setTimeout(r, ms));\n\n // Click inbox icon to open notifications panel\n const inboxIcon = document.querySelector('[data-e2e=\"inbox-icon\"]');\n if (inboxIcon) inboxIcon.click();\n await wait(1500);\n\n // Click specific tab if needed\n if (type !== 'all') {\n const tab = document.querySelector('[data-e2e=\"' + type + '\"]');\n if (tab) {\n tab.click();\n await wait(1500);\n }\n }\n\n const items = document.querySelectorAll('[data-e2e=\"inbox-list\"] > div, [data-e2e=\"inbox-list\"] [role=\"button\"]');\n return Array.from(items)\n .filter(el => el.textContent.trim().length > 5)\n .slice(0, limit)\n .map((el, i) => ({\n index: i + 1,\n text: el.textContent.trim().replace(/\\s+/g, ' ').substring(0, 150),\n }));\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "tiktok/notifications.yaml" + }, + { + "site": "tiktok", + "name": "profile", + "description": "Get TikTok user profile info", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "TikTok username (without @)" + } + ], + "columns": [ + "username", + "name", + "followers", + "following", + "likes", + "videos", + "verified", + "bio" + ], + "pipeline": [ + { + "navigate": { + "url": "https://www.tiktok.com/explore", + "settleMs": 5000 + } + }, + { + "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const res = await fetch('https://www.tiktok.com/@' + encodeURIComponent(username), { credentials: 'include' });\n if (!res.ok) throw new Error('User not found: ' + username);\n const html = await res.text();\n const idx = html.indexOf('__UNIVERSAL_DATA_FOR_REHYDRATION__');\n if (idx === -1) throw new Error('Could not parse profile data');\n const start = html.indexOf('>', idx) + 1;\n const end = html.indexOf('', start);\n const data = JSON.parse(html.substring(start, end));\n const ud = data['__DEFAULT_SCOPE__'] && data['__DEFAULT_SCOPE__']['webapp.user-detail'];\n const u = ud && ud.userInfo && ud.userInfo.user;\n const s = ud && ud.userInfo && ud.userInfo.stats;\n if (!u) throw new Error('User not found: ' + username);\n return [{\n username: u.uniqueId || username,\n name: u.nickname || '',\n bio: (u.signature || '').replace(/\\n/g, ' ').substring(0, 120),\n followers: s && s.followerCount || 0,\n following: s && s.followingCount || 0,\n likes: s && s.heartCount || 0,\n videos: s && s.videoCount || 0,\n verified: u.verified ? 'Yes' : 'No',\n }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "tiktok/profile.yaml" + }, + { + "site": "tiktok", + "name": "save", + "description": "Add a TikTok video to Favorites", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "positional": true, + "help": "TikTok video URL" + } + ], + "columns": [ + "status", + "url" + ], + "pipeline": [ + { + "navigate": { + "url": "${{ args.url }}", + "settleMs": 6000 + } + }, + { + "evaluate": "(async () => {\n const url = ${{ args.url | json }};\n const btn = document.querySelector('[data-e2e=\"bookmark-icon\"]') ||\n document.querySelector('[data-e2e=\"collect-icon\"]');\n if (!btn) throw new Error('Favorites button not found - make sure you are logged in');\n const container = btn.closest('button') || btn.closest('[role=\"button\"]') || btn;\n const aria = (container.getAttribute('aria-label') || '').toLowerCase();\n if (aria.includes('remove from favorites') || aria.includes('取消收藏')) {\n return [{ status: 'Already in Favorites', url: url }];\n }\n container.click();\n await new Promise(r => setTimeout(r, 2000));\n return [{ status: 'Added to Favorites', url: url }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "tiktok/save.yaml" + }, + { + "site": "tiktok", + "name": "search", + "description": "Search TikTok videos", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "positional": false, + "help": "Number of results" + } + ], + "columns": [ + "rank", + "desc", + "author", + "url", + "plays", + "likes", + "comments", + "shares" + ], + "pipeline": [ + { + "navigate": { + "url": "https://www.tiktok.com/explore", + "settleMs": 5000 + } + }, + { + "evaluate": "(async () => {\n const query = ${{ args.query | json }};\n const limit = ${{ args.limit }};\n const res = await fetch('/api/search/general/full/?keyword=' + encodeURIComponent(query) + '&offset=0&count=' + limit + '&aid=1988', { credentials: 'include' });\n if (!res.ok) throw new Error('Search failed: HTTP ' + res.status);\n const data = await res.json();\n const items = (data.data || []).filter(function(i) { return i.type === 1 && i.item; });\n return items.slice(0, limit).map(function(i, idx) {\n var v = i.item;\n var a = v.author || {};\n var s = v.stats || {};\n return {\n rank: idx + 1,\n desc: (v.desc || '').replace(/\\n/g, ' ').substring(0, 100),\n author: a.uniqueId || '',\n url: (a.uniqueId && v.id) ? 'https://www.tiktok.com/@' + a.uniqueId + '/video/' + v.id : '',\n plays: s.playCount || 0,\n likes: s.diggCount || 0,\n comments: s.commentCount || 0,\n shares: s.shareCount || 0,\n };\n });\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "tiktok/search.yaml" + }, + { + "site": "tiktok", + "name": "unfollow", + "description": "Unfollow a TikTok user", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "TikTok username (without @)" + } + ], + "columns": [ + "status", + "username" + ], + "pipeline": [ + { + "navigate": { + "url": "https://www.tiktok.com/@${{ args.username }}", + "settleMs": 6000 + } + }, + { + "evaluate": "(async () => {\n const username = ${{ args.username | json }};\n const buttons = Array.from(document.querySelectorAll('button, [role=\"button\"]'));\n const followingBtn = buttons.find(function(b) {\n var text = b.textContent.trim();\n return text === 'Following' || text === '已关注' || text === 'Friends' || text === '互关';\n });\n if (!followingBtn) {\n return [{ status: 'Not following this user', username: username }];\n }\n followingBtn.click();\n await new Promise(r => setTimeout(r, 2000));\n // Confirm unfollow if dialog appears\n var allBtns = Array.from(document.querySelectorAll('button'));\n var confirm = allBtns.find(function(b) {\n var t = b.textContent.trim();\n return t === 'Unfollow' || t === '取消关注';\n });\n if (confirm) {\n confirm.click();\n await new Promise(r => setTimeout(r, 1500));\n }\n return [{ status: 'Unfollowed', username: username }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "tiktok/unfollow.yaml" + }, + { + "site": "tiktok", + "name": "unlike", + "description": "Unlike a TikTok video", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "positional": true, + "help": "TikTok video URL" + } + ], + "columns": [ + "status", + "likes", + "url" + ], + "pipeline": [ + { + "navigate": { + "url": "${{ args.url }}", + "settleMs": 6000 + } + }, + { + "evaluate": "(async () => {\n const url = ${{ args.url | json }};\n const btn = document.querySelector('[data-e2e=\"like-icon\"]');\n if (!btn) throw new Error('Like button not found - make sure you are logged in');\n const container = btn.closest('button') || btn.closest('[role=\"button\"]') || btn;\n const aria = (container.getAttribute('aria-label') || '').toLowerCase();\n const color = window.getComputedStyle(btn).color;\n const isLiked = aria.includes('unlike') || aria.includes('取消点赞') ||\n (color && (color.includes('255, 65') || color.includes('fe2c55')));\n if (!isLiked) {\n const count = document.querySelector('[data-e2e=\"like-count\"]');\n return [{ status: 'Not liked', likes: count ? count.textContent.trim() : '-', url: url }];\n }\n container.click();\n await new Promise(r => setTimeout(r, 2000));\n const count = document.querySelector('[data-e2e=\"like-count\"]');\n return [{ status: 'Unliked', likes: count ? count.textContent.trim() : '-', url: url }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "tiktok/unlike.yaml" + }, + { + "site": "tiktok", + "name": "unsave", + "description": "Remove a TikTok video from Favorites", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "positional": true, + "help": "TikTok video URL" + } + ], + "columns": [ + "status", + "url" + ], + "pipeline": [ + { + "navigate": { + "url": "${{ args.url }}", + "settleMs": 6000 + } + }, + { + "evaluate": "(async () => {\n const url = ${{ args.url | json }};\n const btn = document.querySelector('[data-e2e=\"bookmark-icon\"]') ||\n document.querySelector('[data-e2e=\"collect-icon\"]');\n if (!btn) throw new Error('Favorites button not found - make sure you are logged in');\n const container = btn.closest('button') || btn.closest('[role=\"button\"]') || btn;\n const aria = (container.getAttribute('aria-label') || '').toLowerCase();\n if (aria.includes('add to favorites') || aria.includes('收藏')) {\n if (!aria.includes('remove') && !aria.includes('取消')) {\n return [{ status: 'Not in Favorites', url: url }];\n }\n }\n container.click();\n await new Promise(r => setTimeout(r, 2000));\n return [{ status: 'Removed from Favorites', url: url }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "tiktok/unsave.yaml" + }, + { + "site": "tiktok", + "name": "user", + "description": "Get recent videos from a TikTok user", + "domain": "www.tiktok.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "TikTok username (without @)" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "positional": false, + "help": "Number of videos" + } + ], + "columns": [ + "index", + "views", + "url" + ], + "pipeline": [ + { + "navigate": { + "url": "https://www.tiktok.com/@${{ args.username }}", + "settleMs": 6000 + } + }, + { + "evaluate": "(() => {\n const limit = ${{ args.limit }};\n const username = ${{ args.username | json }};\n const links = Array.from(document.querySelectorAll('a[href*=\"/video/\"]'));\n const seen = {};\n const results = [];\n for (const a of links) {\n const href = a.href;\n if (seen[href]) continue;\n seen[href] = true;\n results.push({\n index: results.length + 1,\n views: a.textContent.trim() || '-',\n url: href,\n });\n if (results.length >= limit) break;\n }\n if (results.length === 0) throw new Error('No videos found for @' + username);\n return results;\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "tiktok/user.yaml" + }, + { + "site": "v2ex", + "name": "hot", + "description": "V2EX 热门话题", + "domain": "www.v2ex.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of topics" + } + ], + "columns": [ + "id", + "rank", + "title", + "node", + "replies", + "url" + ], + "pipeline": [ + { + "fetch": { + "url": "https://www.v2ex.com/api/topics/hot.json" + } + }, + { + "map": { + "id": "${{ item.id }}", + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "node": "${{ item.node.title }}", + "replies": "${{ item.replies }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "v2ex/hot.yaml" + }, + { + "site": "v2ex", + "name": "latest", + "description": "V2EX 最新话题", + "domain": "www.v2ex.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of topics" + } + ], + "columns": [ + "id", + "rank", + "title", + "node", + "replies", + "url" + ], + "pipeline": [ + { + "fetch": { + "url": "https://www.v2ex.com/api/topics/latest.json" + } + }, + { + "map": { + "id": "${{ item.id }}", + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "node": "${{ item.node.title }}", + "replies": "${{ item.replies }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "v2ex/latest.yaml" + }, + { + "site": "v2ex", + "name": "member", + "description": "V2EX 用户资料", + "domain": "www.v2ex.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Username" + } + ], + "columns": [ + "username", + "tagline", + "website", + "github", + "twitter", + "location" + ], + "pipeline": [ + { + "fetch": { + "url": "https://www.v2ex.com/api/members/show.json", + "params": { + "username": "${{ args.username }}" + } + } + }, + { + "map": { + "username": "${{ item.username }}", + "tagline": "${{ item.tagline }}", + "website": "${{ item.website }}", + "github": "${{ item.github }}", + "twitter": "${{ item.twitter }}", + "location": "${{ item.location }}" + } + } + ], + "type": "yaml", + "sourceFile": "v2ex/member.yaml" + }, + { + "site": "v2ex", + "name": "node", + "description": "V2EX 节点话题列表", + "domain": "www.v2ex.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "name", + "type": "str", + "required": true, + "positional": true, + "help": "Node name (e.g. python, javascript, apple)" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "positional": false, + "help": "Number of topics (API returns max 20)" + } + ], + "columns": [ + "rank", + "title", + "author", + "replies", + "url" + ], + "pipeline": [ + { + "fetch": { + "url": "https://www.v2ex.com/api/topics/show.json", + "params": { + "node_name": "${{ args.name }}" + } + } + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "author": "${{ item.member.username }}", + "replies": "${{ item.replies }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "v2ex/node.yaml" + }, + { + "site": "v2ex", + "name": "nodes", + "description": "V2EX 所有节点列表", + "domain": "www.v2ex.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "limit", + "type": "int", + "default": 30, + "required": false, + "positional": false, + "help": "Number of nodes" + } + ], + "columns": [ + "rank", + "name", + "title", + "topics", + "stars" + ], + "pipeline": [ + { + "fetch": { + "url": "https://www.v2ex.com/api/nodes/all.json" + } + }, + { + "sort": { + "by": "topics", + "order": "desc" + } + }, + { + "map": { + "rank": "${{ index + 1 }}", + "name": "${{ item.name }}", + "title": "${{ item.title }}", + "topics": "${{ item.topics }}", + "stars": "${{ item.stars }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "v2ex/nodes.yaml" + }, + { + "site": "v2ex", + "name": "replies", + "description": "V2EX 主题回复列表", + "domain": "www.v2ex.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "id", + "type": "str", + "required": true, + "positional": true, + "help": "Topic ID" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of replies" + } + ], + "columns": [ + "floor", + "author", + "content" + ], + "pipeline": [ + { + "fetch": { + "url": "https://www.v2ex.com/api/replies/show.json", + "params": { + "topic_id": "${{ args.id }}" + } + } + }, + { + "map": { + "floor": "${{ index + 1 }}", + "author": "${{ item.member.username }}", + "content": "${{ item.content }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "v2ex/replies.yaml" + }, + { + "site": "v2ex", + "name": "topic", + "description": "V2EX 主题详情和回复", + "domain": "www.v2ex.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "id", + "type": "str", + "required": true, + "positional": true, + "help": "Topic ID" + } + ], + "columns": [ + "id", + "title", + "content", + "member", + "created", + "node", + "replies", + "url" + ], + "pipeline": [ + { + "fetch": { + "url": "https://www.v2ex.com/api/topics/show.json", + "params": { + "id": "${{ args.id }}" + } + } + }, + { + "map": { + "id": "${{ item.id }}", + "title": "${{ item.title }}", + "content": "${{ item.content }}", + "member": "${{ item.member.username }}", + "created": "${{ item.created }}", + "node": "${{ item.node.title }}", + "replies": "${{ item.replies }}", + "url": "${{ item.url }}" + } + }, + { + "limit": 1 + } + ], + "type": "yaml", + "sourceFile": "v2ex/topic.yaml" + }, + { + "site": "v2ex", + "name": "user", + "description": "V2EX 用户发帖列表", + "domain": "www.v2ex.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "username", + "type": "str", + "required": true, + "positional": true, + "help": "Username" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "positional": false, + "help": "Number of topics (API returns max 20)" + } + ], + "columns": [ + "rank", + "title", + "node", + "replies", + "url" + ], + "pipeline": [ + { + "fetch": { + "url": "https://www.v2ex.com/api/topics/show.json", + "params": { + "username": "${{ args.username }}" + } + } + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "node": "${{ item.node.title }}", + "replies": "${{ item.replies }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "v2ex/user.yaml" + }, + { + "site": "xiaoe", + "name": "catalog", + "description": "小鹅通课程目录(支持普通课程、专栏、大专栏)", + "domain": "h5.xet.citv.cn", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "positional": true, + "help": "课程页面 URL" + } + ], + "columns": [ + "ch", + "chapter", + "no", + "title", + "type", + "resource_id", + "status" + ], + "pipeline": [ + { + "navigate": "${{ args.url }}" + }, + { + "wait": 8 + }, + { + "evaluate": "(async () => {\n var el = document.querySelector('#app');\n var store = (el && el.__vue__) ? el.__vue__.$store : null;\n if (!store) return [];\n var coreInfo = store.state.coreInfo || {};\n var resourceType = coreInfo.resource_type || 0;\n var origin = window.location.origin;\n var courseName = coreInfo.resource_name || '';\n\n function typeLabel(t) {\n return {1:'图文',2:'直播',3:'音频',4:'视频',6:'专栏',8:'大专栏'}[Number(t)] || String(t||'');\n }\n function buildUrl(item) {\n var u = item.jump_url || item.h5_url || item.url || '';\n return (u && !u.startsWith('http')) ? origin + u : u;\n }\n function clickTab(name) {\n var tabs = document.querySelectorAll('span, div');\n for (var i = 0; i < tabs.length; i++) {\n if (tabs[i].children.length === 0 && tabs[i].textContent.trim() === name) {\n tabs[i].click(); return;\n }\n }\n }\n\n clickTab('目录');\n await new Promise(function(r) { setTimeout(r, 2000); });\n\n // ===== 专栏 / 大专栏 =====\n if (resourceType === 6 || resourceType === 8) {\n await new Promise(function(r) { setTimeout(r, 1000); });\n var listData = [];\n var walkList = function(vm, depth) {\n if (!vm || depth > 6 || listData.length > 0) return;\n var d = vm.$data || {};\n var keys = ['columnList', 'SingleItemList', 'chapterChildren'];\n for (var ki = 0; ki < keys.length; ki++) {\n var arr = d[keys[ki]];\n if (arr && Array.isArray(arr) && arr.length > 0 && arr[0].resource_id) {\n for (var j = 0; j < arr.length; j++) {\n var item = arr[j];\n if (!item.resource_id || !/^[pvlai]_/.test(item.resource_id)) continue;\n listData.push({\n ch: 1, chapter: courseName, no: j + 1,\n title: item.resource_title || item.title || item.chapter_title || '',\n type: typeLabel(item.resource_type || item.chapter_type),\n resource_id: item.resource_id,\n url: buildUrl(item),\n status: item.finished_state === 1 ? '已完成' : (item.resource_count ? item.resource_count + '节' : ''),\n });\n }\n return;\n }\n }\n if (vm.$children) {\n for (var c = 0; c < vm.$children.length; c++) walkList(vm.$children[c], depth + 1);\n }\n };\n walkList(el.__vue__, 0);\n return listData;\n }\n\n // ===== 普通课程 =====\n var chapters = document.querySelectorAll('.chapter_box');\n for (var ci = 0; ci < chapters.length; ci++) {\n var vue = chapters[ci].__vue__;\n if (vue && typeof vue.getSecitonList === 'function' && (!vue.isShowSecitonsList || !vue.chapterChildren.length)) {\n if (vue.isShowSecitonsList) vue.isShowSecitonsList = false;\n try { vue.getSecitonList(); } catch(e) {}\n await new Promise(function(r) { setTimeout(r, 1500); });\n }\n }\n await new Promise(function(r) { setTimeout(r, 3000); });\n\n var result = [];\n chapters = document.querySelectorAll('.chapter_box');\n for (var cj = 0; cj < chapters.length; cj++) {\n var v = chapters[cj].__vue__;\n if (!v) continue;\n var chTitle = (v.chapterItem && v.chapterItem.chapter_title) || '';\n var children = v.chapterChildren || [];\n for (var ck = 0; ck < children.length; ck++) {\n var child = children[ck];\n var resId = child.resource_id || child.chapter_id || '';\n var chType = child.chapter_type || child.resource_type || 0;\n var urlPath = {1:'/v1/course/text/',2:'/v2/course/alive/',3:'/v1/course/audio/',4:'/v1/course/video/'}[Number(chType)];\n result.push({\n ch: cj + 1, chapter: chTitle, no: ck + 1,\n title: child.chapter_title || child.resource_title || '',\n type: typeLabel(chType),\n resource_id: resId,\n url: urlPath ? origin + urlPath + resId + '?type=2' : '',\n status: child.is_finish === 1 ? '已完成' : (child.learn_progress > 0 ? child.learn_progress + '%' : '未学'),\n });\n }\n }\n return result;\n})()\n" + }, + { + "map": { + "ch": "${{ item.ch }}", + "chapter": "${{ item.chapter }}", + "no": "${{ item.no }}", + "title": "${{ item.title }}", + "type": "${{ item.type }}", + "resource_id": "${{ item.resource_id }}", + "url": "${{ item.url }}", + "status": "${{ item.status }}" + } + } + ], + "type": "yaml", + "sourceFile": "xiaoe/catalog.yaml" + }, + { + "site": "xiaoe", + "name": "content", + "description": "提取小鹅通图文页面内容为文本", + "domain": "h5.xet.citv.cn", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "positional": true, + "help": "页面 URL" + } + ], + "columns": [ + "title", + "content_length", + "image_count" + ], + "pipeline": [ + { + "navigate": "${{ args.url }}" + }, + { + "wait": 6 + }, + { + "evaluate": "(() => {\n var selectors = ['.rich-text-wrap','.content-wrap','.article-content','.text-content',\n '.course-detail','.detail-content','[class*=\"richtext\"]','[class*=\"rich-text\"]','.ql-editor'];\n var content = '';\n for (var i = 0; i < selectors.length; i++) {\n var el = document.querySelector(selectors[i]);\n if (el && el.innerText.trim().length > 50) { content = el.innerText.trim(); break; }\n }\n if (!content) content = (document.querySelector('main') || document.querySelector('#app') || document.body).innerText.trim();\n\n var images = [];\n document.querySelectorAll('img').forEach(function(img) {\n if (img.src && !img.src.startsWith('data:') && img.src.includes('xiaoe')) images.push(img.src);\n });\n return [{\n title: document.title,\n content: content,\n content_length: content.length,\n image_count: images.length,\n images: JSON.stringify(images.slice(0, 20)),\n }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "xiaoe/content.yaml" + }, + { + "site": "xiaoe", + "name": "courses", + "description": "列出已购小鹅通课程(含 URL 和店铺名)", + "domain": "study.xiaoe-tech.com", + "strategy": "cookie", + "browser": true, + "args": [], + "columns": [ + "title", + "shop", + "url" + ], + "pipeline": [ + { + "navigate": "https://study.xiaoe-tech.com/" + }, + { + "wait": 8 + }, + { + "evaluate": "(async () => {\n // 切换到「内容」tab\n var tabs = document.querySelectorAll('span, div');\n for (var i = 0; i < tabs.length; i++) {\n if (tabs[i].children.length === 0 && tabs[i].textContent.trim() === '内容') {\n tabs[i].click();\n break;\n }\n }\n await new Promise(function(r) { setTimeout(r, 2000); });\n\n // 匹配课程卡片标题与 Vue 数据\n function matchEntry(title, vm, depth) {\n if (!vm || depth > 5) return null;\n var d = vm.$data || {};\n for (var k in d) {\n if (!Array.isArray(d[k])) continue;\n for (var j = 0; j < d[k].length; j++) {\n var e = d[k][j];\n if (!e || typeof e !== 'object') continue;\n var t = e.title || e.resource_name || '';\n if (t && title.includes(t.substring(0, 10))) return e;\n }\n }\n return vm.$parent ? matchEntry(title, vm.$parent, depth + 1) : null;\n }\n\n // 构造课程 URL\n function buildUrl(entry) {\n if (entry.h5_url) return entry.h5_url;\n if (entry.url) return entry.url;\n if (entry.app_id && entry.resource_id) {\n var base = 'https://' + entry.app_id + '.h5.xet.citv.cn';\n if (entry.resource_type === 6) return base + '/v1/course/column/' + entry.resource_id + '?type=3';\n return base + '/p/course/ecourse/' + entry.resource_id;\n }\n return '';\n }\n\n var cards = document.querySelectorAll('.course-card-list');\n var results = [];\n for (var c = 0; c < cards.length; c++) {\n var titleEl = cards[c].querySelector('.card-title-box');\n var title = titleEl ? titleEl.textContent.trim() : '';\n if (!title) continue;\n var entry = matchEntry(title, cards[c].__vue__, 0);\n results.push({\n title: title,\n shop: entry ? (entry.shop_name || entry.app_name || '') : '',\n url: entry ? buildUrl(entry) : '',\n });\n }\n return results;\n})()\n" + }, + { + "map": { + "title": "${{ item.title }}", + "shop": "${{ item.shop }}", + "url": "${{ item.url }}" + } + } + ], + "type": "yaml", + "sourceFile": "xiaoe/courses.yaml" + }, + { + "site": "xiaoe", + "name": "detail", + "description": "小鹅通课程详情(名称、价格、学员数、店铺)", + "domain": "h5.xet.citv.cn", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "positional": true, + "help": "课程页面 URL" + } + ], + "columns": [ + "name", + "price", + "original_price", + "user_count", + "shop_name" + ], + "pipeline": [ + { + "navigate": "${{ args.url }}" + }, + { + "wait": 5 + }, + { + "evaluate": "(() => {\n var vm = (document.querySelector('#app') || {}).__vue__;\n if (!vm || !vm.$store) return [];\n var core = vm.$store.state.coreInfo || {};\n var goods = vm.$store.state.goodsInfo || {};\n var shop = ((vm.$store.state.compositeInfo || {}).shop_conf) || {};\n return [{\n name: core.resource_name || '',\n resource_id: core.resource_id || '',\n resource_type: core.resource_type || '',\n cover: core.resource_img || '',\n user_count: core.user_count || 0,\n price: goods.price ? (goods.price / 100).toFixed(2) : '0',\n original_price: goods.line_price ? (goods.line_price / 100).toFixed(2) : '0',\n is_free: goods.is_free || 0,\n shop_name: shop.shop_name || '',\n }];\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "xiaoe/detail.yaml" + }, + { + "site": "xiaoe", + "name": "play-url", + "description": "小鹅通视频/音频/直播回放 M3U8 播放地址", + "domain": "h5.xet.citv.cn", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "url", + "type": "str", + "required": true, + "positional": true, + "help": "小节页面 URL" + } + ], + "columns": [ + "title", + "resource_id", + "m3u8_url", + "duration_sec", + "method" + ], + "pipeline": [ + { + "navigate": "${{ args.url }}" + }, + { + "wait": 2 + }, + { + "evaluate": "(async () => {\n var pageUrl = window.location.href;\n var origin = window.location.origin;\n var resourceId = (pageUrl.match(/[val]_[a-f0-9]+/) || [])[0] || '';\n var productId = (pageUrl.match(/product_id=([^&]+)/) || [])[1] || '';\n var appId = (origin.match(/(app[a-z0-9]+)\\./) || [])[1] || '';\n var isLive = resourceId.startsWith('l_') || pageUrl.includes('/alive/');\n var m3u8Url = '', method = '', title = document.title, duration = 0;\n\n // 深度搜索 Vue 组件树找 M3U8\n function searchVueM3u8() {\n var el = document.querySelector('#app');\n if (!el || !el.__vue__) return '';\n var walk = function(vm, d) {\n if (!vm || d > 10) return '';\n var data = vm.$data || {};\n for (var k in data) {\n if (k[0] === '_' || k[0] === '$') continue;\n var v = data[k];\n if (typeof v === 'string' && v.includes('.m3u8')) return v;\n if (typeof v === 'object' && v) {\n try {\n var s = JSON.stringify(v);\n var m = s.match(/https?:[^\"]*\\.m3u8[^\"]*/);\n if (m) return m[0].replace(/\\\\\\//g, '/');\n } catch(e) {}\n }\n }\n if (vm.$children) {\n for (var c = 0; c < vm.$children.length; c++) {\n var f = walk(vm.$children[c], d + 1);\n if (f) return f;\n }\n }\n return '';\n };\n return walk(el.__vue__, 0);\n }\n\n // ===== 视频课: detail_info → getPlayUrl =====\n if (!isLive && resourceId.startsWith('v_')) {\n try {\n var detailRes = await fetch(origin + '/xe.course.business.video.detail_info.get/2.0.0', {\n method: 'POST', credentials: 'include',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n 'bizData[resource_id]': resourceId,\n 'bizData[product_id]': productId || resourceId,\n 'bizData[opr_sys]': 'MacIntel',\n }),\n });\n var detail = await detailRes.json();\n var vi = (detail.data || {}).video_info || {};\n title = vi.file_name || title;\n duration = vi.video_length || 0;\n if (vi.play_sign) {\n var userId = (document.cookie.match(/ctx_user_id=([^;]+)/) || [])[1] || window.__user_id || '';\n var playRes = await fetch(origin + '/xe.material-center.play/getPlayUrl', {\n method: 'POST', credentials: 'include',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n org_app_id: appId, app_id: vi.material_app_id || appId,\n user_id: userId, play_sign: [vi.play_sign],\n play_line: 'A', opr_sys: 'MacIntel',\n }),\n });\n var playData = await playRes.json();\n if (playData.code === 0 && playData.data) {\n var m = JSON.stringify(playData.data).match(/https?:[^\"]*\\.m3u8[^\"]*/);\n if (m) { m3u8Url = m[0].replace(/\\\\u0026/g, '&').replace(/\\\\\\//g, '/'); method = 'api_direct'; }\n }\n }\n } catch(e) {}\n }\n\n // ===== 兜底: Performance API + Vue 搜索轮询 =====\n if (!m3u8Url) {\n for (var attempt = 0; attempt < 30; attempt++) {\n var entries = performance.getEntriesByType('resource');\n for (var i = 0; i < entries.length; i++) {\n if (entries[i].name.includes('.m3u8')) { m3u8Url = entries[i].name; method = 'perf_api'; break; }\n }\n if (!m3u8Url) { m3u8Url = searchVueM3u8(); if (m3u8Url) method = 'vue_search'; }\n if (m3u8Url) break;\n await new Promise(function(r) { setTimeout(r, 500); });\n }\n }\n\n if (!duration) {\n var vid = document.querySelector('video'), aud = document.querySelector('audio');\n if (vid && vid.duration && !isNaN(vid.duration)) duration = Math.round(vid.duration);\n if (aud && aud.duration && !isNaN(aud.duration)) duration = Math.round(aud.duration);\n }\n\n return [{ title: title, resource_id: resourceId, m3u8_url: m3u8Url, duration_sec: duration, method: method }];\n})()\n" + }, + { + "map": { + "title": "${{ item.title }}", + "resource_id": "${{ item.resource_id }}", + "m3u8_url": "${{ item.m3u8_url }}", + "duration_sec": "${{ item.duration_sec }}", + "method": "${{ item.method }}" + } + } + ], + "type": "yaml", + "sourceFile": "xiaoe/play-url.yaml" + }, + { + "site": "xiaohongshu", + "name": "feed", + "description": "小红书首页推荐 Feed (via Pinia Store Action)", + "domain": "www.xiaohongshu.com", + "strategy": "intercept", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of items to return" + } + ], + "columns": [ + "title", + "author", + "likes", + "type", + "url" + ], + "pipeline": [ + { + "navigate": "https://www.xiaohongshu.com/explore" + }, + { + "tap": { + "store": "feed", + "action": "fetchFeeds", + "capture": "homefeed", + "select": "data.items", + "timeout": 8 + } + }, + { + "map": { + "id": "${{ item.id }}", + "title": "${{ item.note_card.display_title }}", + "type": "${{ item.note_card.type }}", + "author": "${{ item.note_card.user.nickname }}", + "likes": "${{ item.note_card.interact_info.liked_count }}", + "url": "https://www.xiaohongshu.com/explore/${{ item.id }}" + } + }, + { + "limit": "${{ args.limit | default(20) }}" + } + ], + "type": "yaml", + "sourceFile": "xiaohongshu/feed.yaml" + }, + { + "site": "xiaohongshu", + "name": "notifications", + "description": "小红书通知 (mentions/likes/connections)", + "domain": "www.xiaohongshu.com", + "strategy": "intercept", + "browser": true, + "args": [ + { + "name": "type", + "type": "str", + "default": "mentions", + "required": false, + "positional": false, + "help": "Notification type: mentions, likes, or connections" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of notifications to return" + } + ], + "columns": [ + "rank", + "user", + "action", + "content", + "note", + "time" + ], + "pipeline": [ + { + "navigate": "https://www.xiaohongshu.com/notification" + }, + { + "tap": { + "store": "notification", + "action": "getNotification", + "args": [ + "${{ args.type | default('mentions') }}" + ], + "capture": "/you/", + "select": "data.message_list", + "timeout": 8 + } + }, + { + "map": { + "rank": "${{ index + 1 }}", + "user": "${{ item.user_info.nickname }}", + "action": "${{ item.title }}", + "content": "${{ item.comment_info.content }}", + "note": "${{ item.item_info.content }}", + "time": "${{ item.time }}" + } + }, + { + "limit": "${{ args.limit | default(20) }}" + } + ], + "type": "yaml", + "sourceFile": "xiaohongshu/notifications.yaml" + }, + { + "site": "xueqiu", + "name": "earnings-date", + "description": "获取股票预计财报发布日期(公司大事)", + "domain": "xueqiu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "symbol", + "type": "str", + "required": true, + "positional": true, + "help": "股票代码,如 SH600519、SZ000858、00700" + }, + { + "name": "next", + "type": "bool", + "default": false, + "required": false, + "positional": false, + "help": "仅返回最近一次未发布的财报日期" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "positional": false, + "help": "返回数量,默认 10" + } + ], + "columns": [ + "date", + "report", + "status" + ], + "pipeline": [ + { + "navigate": "https://xueqiu.com" + }, + { + "evaluate": "(async () => {\n const symbol = (${{ args.symbol | json }} || '').toUpperCase();\n const onlyNext = ${{ args.next }};\n if (!symbol) throw new Error('Missing argument: symbol');\n const resp = await fetch(\n `https://stock.xueqiu.com/v5/stock/screener/event/list.json?symbol=${encodeURIComponent(symbol)}&page=1&size=100`,\n { credentials: 'include' }\n );\n if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');\n const d = await resp.json();\n if (!d.data || !d.data.items) throw new Error('获取失败: ' + JSON.stringify(d));\n\n // subtype 2 = 预计财报发布\n let items = d.data.items.filter(item => item.subtype === 2);\n\n const now = Date.now();\n let results = items.map(item => {\n const ts = item.timestamp;\n const dateStr = ts ? new Date(ts).toISOString().split('T')[0] : null;\n const isFuture = ts && ts > now;\n return {\n date: dateStr,\n report: item.message,\n status: isFuture ? '⏳ 未发布' : '✅ 已发布',\n _ts: ts,\n _future: isFuture\n };\n });\n\n if (onlyNext) {\n const future = results.filter(r => r._future).sort((a, b) => a._ts - b._ts);\n results = future.length ? [future[0]] : [];\n }\n\n return results;\n})()\n" + }, + { + "map": { + "date": "${{ item.date }}", + "report": "${{ item.report }}", + "status": "${{ item.status }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "xueqiu/earnings-date.yaml" + }, + { + "site": "xueqiu", + "name": "feed", + "description": "获取雪球首页时间线(关注用户的动态)", + "domain": "xueqiu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "page", + "type": "int", + "default": 1, + "required": false, + "positional": false, + "help": "页码,默认 1" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "每页数量,默认 20" + } + ], + "columns": [ + "author", + "text", + "likes", + "replies", + "url" + ], + "pipeline": [ + { + "navigate": "https://xueqiu.com" + }, + { + "evaluate": "(async () => {\n const page = ${{ args.page }};\n const count = ${{ args.limit }};\n const resp = await fetch(`https://xueqiu.com/v4/statuses/home_timeline.json?page=${page}&count=${count}`, {credentials: 'include'});\n if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');\n const d = await resp.json();\n \n const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim();\n const list = d.home_timeline || d.list || [];\n return list.map(item => {\n const user = item.user || {};\n return {\n id: item.id,\n text: strip(item.description).substring(0, 200),\n url: 'https://xueqiu.com/' + user.id + '/' + item.id,\n author: user.screen_name,\n likes: item.fav_count,\n retweets: item.retweet_count,\n replies: item.reply_count,\n created_at: item.created_at ? new Date(item.created_at).toISOString() : null\n };\n });\n})()\n" + }, + { + "map": { + "author": "${{ item.author }}", + "text": "${{ item.text }}", + "likes": "${{ item.likes }}", + "replies": "${{ item.replies }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "xueqiu/feed.yaml" + }, + { + "site": "xueqiu", + "name": "groups", + "description": "获取雪球自选股分组列表(含模拟组合)", + "domain": "xueqiu.com", + "strategy": "cookie", + "browser": true, + "args": [], + "columns": [ + "pid", + "name", + "count" + ], + "pipeline": [ + { + "navigate": "https://xueqiu.com" + }, + { + "evaluate": "(async () => {\n const resp = await fetch('https://stock.xueqiu.com/v5/stock/portfolio/list.json?category=1&size=20', {credentials: 'include'});\n if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');\n const d = await resp.json();\n if (!d.data || !d.data.stocks) throw new Error('获取失败,可能未登录');\n\n return d.data.stocks.map(g => ({\n pid: String(g.id),\n name: g.name,\n count: g.symbol_count || 0\n }));\n})()\n" + } + ], + "type": "yaml", + "sourceFile": "xueqiu/groups.yaml" + }, + { + "site": "xueqiu", + "name": "hot", + "description": "获取雪球热门动态", + "domain": "xueqiu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "返回数量,默认 20,最大 50" + } + ], + "columns": [ + "rank", + "author", + "text", + "likes", + "url" + ], + "pipeline": [ + { + "navigate": "https://xueqiu.com" + }, + { + "evaluate": "(async () => {\n const resp = await fetch('https://xueqiu.com/statuses/hot/listV3.json?source=hot&page=1', {credentials: 'include'});\n if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');\n const d = await resp.json();\n const list = d.list || [];\n \n const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim();\n return list.map((item, i) => {\n const user = item.user || {};\n return {\n rank: i + 1,\n text: strip(item.description).substring(0, 200),\n url: 'https://xueqiu.com/' + user.id + '/' + item.id,\n author: user.screen_name,\n likes: item.fav_count,\n retweets: item.retweet_count,\n replies: item.reply_count\n };\n });\n})()\n" + }, + { + "map": { + "rank": "${{ item.rank }}", + "author": "${{ item.author }}", + "text": "${{ item.text }}", + "likes": "${{ item.likes }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "xueqiu/hot.yaml" + }, + { + "site": "xueqiu", + "name": "hot-stock", + "description": "获取雪球热门股票榜", + "domain": "xueqiu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "返回数量,默认 20,最大 50" + }, + { + "name": "type", + "type": "str", + "default": "10", + "required": false, + "positional": false, + "help": "榜单类型 10=人气榜(默认) 12=关注榜" + } + ], + "columns": [ + "rank", + "symbol", + "name", + "price", + "changePercent", + "heat" + ], + "pipeline": [ + { + "navigate": "https://xueqiu.com" + }, + { + "evaluate": "(async () => {\n const count = ${{ args.limit }};\n const type = ${{ args.type | json }};\n const resp = await fetch(`https://stock.xueqiu.com/v5/stock/hot_stock/list.json?size=${count}&type=${type}`, {credentials: 'include'});\n if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');\n const d = await resp.json();\n if (!d.data || !d.data.items) throw new Error('获取失败');\n return d.data.items.map((s, i) => ({\n rank: i + 1,\n symbol: s.symbol,\n name: s.name,\n price: s.current,\n changePercent: s.percent != null ? s.percent.toFixed(2) + '%' : null,\n heat: s.value,\n rank_change: s.rank_change,\n url: 'https://xueqiu.com/S/' + s.symbol\n }));\n})()\n" + }, + { + "map": { + "rank": "${{ item.rank }}", + "symbol": "${{ item.symbol }}", + "name": "${{ item.name }}", + "price": "${{ item.price }}", + "changePercent": "${{ item.changePercent }}", + "heat": "${{ item.heat }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "xueqiu/hot-stock.yaml" + }, + { + "site": "xueqiu", + "name": "kline", + "description": "获取雪球股票K线(历史行情)数据", + "domain": "xueqiu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "symbol", + "type": "str", + "required": true, + "positional": true, + "help": "股票代码,如 SH600519、SZ000858、AAPL" + }, + { + "name": "days", + "type": "int", + "default": 14, + "required": false, + "positional": false, + "help": "回溯天数(默认14天)" + } + ], + "columns": [ + "date", + "open", + "high", + "low", + "close", + "volume" + ], + "pipeline": [ + { + "navigate": "https://xueqiu.com" + }, + { + "evaluate": "(async () => {\n const symbol = (${{ args.symbol | json }} || '').toUpperCase();\n const days = parseInt(${{ args.days | json }}) || 14;\n if (!symbol) throw new Error('Missing argument: symbol');\n\n // begin = now minus days (for count=-N, returns N items ending at begin)\n const beginTs = Date.now();\n const resp = await fetch('https://stock.xueqiu.com/v5/stock/chart/kline.json?symbol=' + encodeURIComponent(symbol) + '&begin=' + beginTs + '&period=day&type=before&count=-' + days, {credentials: 'include'});\n if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');\n const d = await resp.json();\n\n if (!d.data || !d.data.item || d.data.item.length === 0) return [];\n\n const columns = d.data.column || [];\n const items = d.data.item || [];\n const colIdx = {};\n columns.forEach((name, i) => { colIdx[name] = i; });\n\n function fmt(v) { return v == null ? null : v; }\n\n return items.map(row => ({\n date: colIdx.timestamp != null ? new Date(row[colIdx.timestamp]).toISOString().split('T')[0] : null,\n open: fmt(row[colIdx.open]),\n high: fmt(row[colIdx.high]),\n low: fmt(row[colIdx.low]),\n close: fmt(row[colIdx.close]),\n volume: fmt(row[colIdx.volume]),\n amount: fmt(row[colIdx.amount]),\n chg: fmt(row[colIdx.chg]),\n percent: fmt(row[colIdx.percent]),\n symbol: symbol\n }));\n})()\n" + }, + { + "map": { + "date": "${{ item.date }}", + "open": "${{ item.open }}", + "high": "${{ item.high }}", + "low": "${{ item.low }}", + "close": "${{ item.close }}", + "volume": "${{ item.volume }}", + "percent": "${{ item.percent }}" + } + } + ], + "type": "yaml", + "sourceFile": "xueqiu/kline.yaml" + }, + { + "site": "xueqiu", + "name": "search", + "description": "搜索雪球股票(代码或名称)", + "domain": "xueqiu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "搜索关键词,如 茅台、AAPL、腾讯" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "positional": false, + "help": "返回数量,默认 10" + } + ], + "columns": [ + "symbol", + "name", + "exchange", + "price", + "changePercent", + "url" + ], + "pipeline": [ + { + "navigate": "https://xueqiu.com" + }, + { + "evaluate": "(async () => {\n const query = ${{ args.query | json }};\n const count = ${{ args.limit }};\n const resp = await fetch(`https://xueqiu.com/stock/search.json?code=${encodeURIComponent(query)}&size=${count}`, {credentials: 'include'});\n if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');\n const d = await resp.json();\n return (d.stocks || []).map(s => {\n let symbol = '';\n if (s.exchange === 'SH' || s.exchange === 'SZ' || s.exchange === 'BJ') {\n symbol = s.code.startsWith(s.exchange) ? s.code : s.exchange + s.code;\n } else {\n symbol = s.code;\n }\n return {\n symbol: symbol,\n name: s.name,\n exchange: s.exchange,\n price: s.current,\n changePercent: s.percentage != null ? s.percentage.toFixed(2) + '%' : null,\n url: 'https://xueqiu.com/S/' + symbol\n };\n });\n})()\n" + }, + { + "map": { + "symbol": "${{ item.symbol }}", + "name": "${{ item.name }}", + "exchange": "${{ item.exchange }}", + "price": "${{ item.price }}", + "changePercent": "${{ item.changePercent }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "xueqiu/search.yaml" + }, + { + "site": "xueqiu", + "name": "stock", + "description": "获取雪球股票实时行情", + "domain": "xueqiu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "symbol", + "type": "str", + "required": true, + "positional": true, + "help": "股票代码,如 SH600519、SZ000858、AAPL、00700" + } + ], + "columns": [ + "name", + "symbol", + "price", + "changePercent", + "marketCap" + ], + "pipeline": [ + { + "navigate": "https://xueqiu.com" + }, + { + "evaluate": "(async () => {\n const symbol = (${{ args.symbol | json }} || '').toUpperCase();\n if (!symbol) throw new Error('Missing argument: symbol');\n const resp = await fetch(`https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol=${encodeURIComponent(symbol)}`, {credentials: 'include'});\n if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');\n const d = await resp.json();\n if (!d.data || !d.data.items || d.data.items.length === 0) throw new Error('未找到股票: ' + symbol);\n \n function fmtAmount(v) {\n if (v == null) return null;\n if (Math.abs(v) >= 1e12) return (v / 1e12).toFixed(2) + '万亿';\n if (Math.abs(v) >= 1e8) return (v / 1e8).toFixed(2) + '亿';\n if (Math.abs(v) >= 1e4) return (v / 1e4).toFixed(2) + '万';\n return v.toString();\n }\n \n const item = d.data.items[0];\n const q = item.quote || {};\n const m = item.market || {};\n \n return [{\n name: q.name,\n symbol: q.symbol,\n exchange: q.exchange,\n currency: q.currency,\n price: q.current,\n change: q.chg,\n changePercent: q.percent != null ? q.percent.toFixed(2) + '%' : null,\n open: q.open,\n high: q.high,\n low: q.low,\n prevClose: q.last_close,\n amplitude: q.amplitude != null ? q.amplitude.toFixed(2) + '%' : null,\n volume: q.volume,\n amount: fmtAmount(q.amount),\n turnover_rate: q.turnover_rate != null ? q.turnover_rate.toFixed(2) + '%' : null,\n marketCap: fmtAmount(q.market_capital),\n floatMarketCap: fmtAmount(q.float_market_capital),\n ytdPercent: q.current_year_percent != null ? q.current_year_percent.toFixed(2) + '%' : null,\n market_status: m.status || null,\n time: q.timestamp ? new Date(q.timestamp).toISOString() : null,\n url: 'https://xueqiu.com/S/' + q.symbol\n }];\n})()\n" + }, + { + "map": { + "name": "${{ item.name }}", + "symbol": "${{ item.symbol }}", + "price": "${{ item.price }}", + "changePercent": "${{ item.changePercent }}", + "marketCap": "${{ item.marketCap }}" + } + } + ], + "type": "yaml", + "sourceFile": "xueqiu/stock.yaml" + }, + { + "site": "xueqiu", + "name": "watchlist", + "description": "获取雪球自选股/模拟组合股票列表", + "domain": "xueqiu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "pid", + "type": "str", + "default": "-1", + "required": false, + "positional": false, + "help": "分组ID:-1=全部(默认) -4=模拟 -5=沪深 -6=美股 -7=港股 -10=实盘 0=持仓(通过 xueqiu groups 获取)" + }, + { + "name": "limit", + "type": "int", + "default": 100, + "required": false, + "positional": false, + "help": "默认 100" + } + ], + "columns": [ + "symbol", + "name", + "price", + "changePercent" + ], + "pipeline": [ + { + "navigate": "https://xueqiu.com" + }, + { + "evaluate": "(async () => {\n const pid = ${{ args.pid | json }} || '-1';\n const resp = await fetch(`https://stock.xueqiu.com/v5/stock/portfolio/stock/list.json?size=100&category=1&pid=${encodeURIComponent(pid)}`, {credentials: 'include'});\n if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');\n const d = await resp.json();\n if (!d.data || !d.data.stocks) throw new Error('获取失败,可能未登录');\n\n return d.data.stocks.map(s => ({\n symbol: s.symbol,\n name: s.name,\n price: s.current,\n change: s.chg,\n changePercent: s.percent != null ? s.percent.toFixed(2) + '%' : null,\n volume: s.volume,\n url: 'https://xueqiu.com/S/' + s.symbol\n }));\n})()\n" + }, + { + "map": { + "symbol": "${{ item.symbol }}", + "name": "${{ item.name }}", + "price": "${{ item.price }}", + "changePercent": "${{ item.changePercent }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "xueqiu/watchlist.yaml" + }, + { + "site": "zhihu", + "name": "hot", + "description": "知乎热榜", + "domain": "www.zhihu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "positional": false, + "help": "Number of items to return" + } + ], + "columns": [ + "rank", + "title", + "heat", + "answers" + ], + "pipeline": [ + { + "navigate": "https://www.zhihu.com" + }, + { + "evaluate": "(async () => {\n const res = await fetch('https://www.zhihu.com/api/v3/feed/topstory/hot-lists/total?limit=50', {\n credentials: 'include'\n });\n const text = await res.text();\n const d = JSON.parse(\n text.replace(/(\"id\"\\s*:\\s*)(\\d{16,})/g, '$1\"$2\"')\n );\n return (d?.data || []).map((item) => {\n const t = item.target || {};\n const questionId = t.id == null ? '' : String(t.id);\n return {\n title: t.title,\n url: 'https://www.zhihu.com/question/' + questionId,\n answer_count: t.answer_count,\n follower_count: t.follower_count,\n heat: item.detail_text || '',\n };\n });\n})()\n" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "heat": "${{ item.heat }}", + "answers": "${{ item.answer_count }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "zhihu/hot.yaml" + }, + { + "site": "zhihu", + "name": "search", + "description": "知乎搜索", + "domain": "www.zhihu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search query" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "positional": false, + "help": "Number of results" + } + ], + "columns": [ + "rank", + "title", + "type", + "author", + "votes", + "url" + ], + "pipeline": [ + { + "navigate": "https://www.zhihu.com" + }, + { + "evaluate": "(async () => {\n const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(//g, '').replace(/<\\/em>/g, '').trim();\n const keyword = ${{ args.query | json }};\n const limit = ${{ args.limit }};\n const res = await fetch('https://www.zhihu.com/api/v4/search_v3?q=' + encodeURIComponent(keyword) + '&t=general&offset=0&limit=' + limit, {\n credentials: 'include'\n });\n const d = await res.json();\n return (d?.data || [])\n .filter(item => item.type === 'search_result')\n .map(item => {\n const obj = item.object || {};\n const q = obj.question || {};\n return {\n type: obj.type,\n title: strip(obj.title || q.name || ''),\n excerpt: strip(obj.excerpt || '').substring(0, 100),\n author: obj.author?.name || '',\n votes: obj.voteup_count || 0,\n url: obj.type === 'answer'\n ? 'https://www.zhihu.com/question/' + q.id + '/answer/' + obj.id\n : obj.type === 'article'\n ? 'https://zhuanlan.zhihu.com/p/' + obj.id\n : 'https://www.zhihu.com/question/' + obj.id,\n };\n });\n})()\n" + }, + { + "map": { + "rank": "${{ index + 1 }}", + "title": "${{ item.title }}", + "type": "${{ item.type }}", + "author": "${{ item.author }}", + "votes": "${{ item.votes }}", + "url": "${{ item.url }}" + } + }, + { + "limit": "${{ args.limit }}" + } + ], + "type": "yaml", + "sourceFile": "zhihu/search.yaml" + } +] \ No newline at end of file diff --git a/clis/saky/commands.test.ts b/clis/saky/commands.test.ts new file mode 100644 index 000000000..0d59cb34a --- /dev/null +++ b/clis/saky/commands.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Strategy, getRegistry } from '@jackwener/opencli/registry'; +import './docs.js'; +import './tool.js'; +import './electronics.js'; +import './formula.js'; + +describe('saky command registration', () => { + it('registers docs and all three query commands', () => { + const docs = getRegistry().get('saky/docs'); + const tool = getRegistry().get('saky/tool'); + const electronics = getRegistry().get('saky/electronics'); + const formula = getRegistry().get('saky/formula'); + const alias = getRegistry().get('saky/elec'); + + expect(docs).toBeDefined(); + expect(tool).toBeDefined(); + expect(electronics).toBeDefined(); + expect(formula).toBeDefined(); + expect(alias).toBe(electronics); + expect(docs?.strategy).toBe(Strategy.PUBLIC); + expect(tool?.strategy).toBe(Strategy.PUBLIC); + expect(electronics?.strategy).toBe(Strategy.PUBLIC); + expect(formula?.strategy).toBe(Strategy.PUBLIC); + expect(docs?.browser).toBe(false); + expect(tool?.browser).toBe(false); + expect(electronics?.browser).toBe(false); + expect(formula?.browser).toBe(false); + }); +}); + +describe('saky docs command', () => { + it('returns the dataset summary rows', async () => { + const docs = getRegistry().get('saky/docs'); + expect(docs?.func).toBeTypeOf('function'); + + const result = await docs!.func!(null as never, {}); + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ command: 'saky/tool', endpoint: '/warehouse_ai/product_label_info_tool' }), + expect.objectContaining({ command: 'saky/electronics', endpoint: '/warehouse_ai/product_label_info_electronics' }), + expect.objectContaining({ command: 'saky/formula', endpoint: '/warehouse_ai/product_label_info_formula' }), + ]), + ); + }); +}); + +describe('saky query commands', () => { + beforeEach(() => { + vi.restoreAllMocks(); + delete process.env.SAKY_APPCODE; + delete process.env.SAKY_APP_CODE; + delete process.env.SAKY_BASE_URL; + delete process.env.SAKY_PT; + }); + + it('uses APPCODE auth and maps tool rows', async () => { + process.env.SAKY_APPCODE = 'test-code'; + const tool = getRegistry().get('saky/tool'); + expect(tool?.func).toBeTypeOf('function'); + + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + errCode: 0, + errMsg: 'success', + requestId: 'req-1', + data: { + totalNum: 12, + pageSize: 5, + pageNum: 2, + rows: [ + { id: '1', cpmc: '成人牙刷', cpmcdh: '成人牙刷-产品', cptm: '6900001', cppl: '成人牙刷', pt: '20260409' }, + ], + }, + }), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + const result = await tool!.func!(null as never, { + 'page-num': 2, + 'page-size': 5, + pt: '20260409', + 'return-total-num': false, + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://dataapi.weimeizi.com/warehouse/warehouse_ai/product_label_info_tool?pageNum=2&pageSize=5&pt=20260409&returnTotalNum=false', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'APPCODE test-code', + 'Content-Type': 'application/json', + }), + }), + ); + expect(result).toEqual([ + expect.objectContaining({ + id: '1', + cpmc: '成人牙刷', + cptm: '6900001', + _requestId: 'req-1', + _pageNum: 2, + _pageSize: 5, + _totalNum: 12, + }), + ]); + }); + + it('accepts app-code and base-url overrides', async () => { + const formula = getRegistry().get('saky/formula'); + expect(formula?.func).toBeTypeOf('function'); + + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + errCode: 0, + errMsg: 'success', + requestId: 'req-2', + data: { + totalNum: 1, + pageSize: 1, + pageNum: 1, + rows: [ + { id: 9, cpmc: '美白牙膏', cptxm: '6900009', cppl: '牙膏', pt: '20260409' }, + ], + }, + }), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await formula!.func!(null as never, { + 'app-code': 'override-code', + 'base-url': 'https://internal.example.com/warehouse/', + 'page-num': 1, + 'page-size': 1, + pt: '20260409', + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://internal.example.com/warehouse/warehouse_ai/product_label_info_formula?pageNum=1&pageSize=1&pt=20260409&returnTotalNum=true', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'APPCODE override-code', + }), + }), + ); + }); + + it('raises a helpful error for warehouse SQL failures', async () => { + process.env.SAKY_APPCODE = 'test-code'; + const electronics = getRegistry().get('saky/electronics'); + expect(electronics?.func).toBeTypeOf('function'); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ + errCode: 1108110565, + errMsg: 'An error occurred while executing the SQL statement.', + requestId: 'req-3', + data: { rows: [] }, + }), { status: 200 }))); + + await expect(electronics!.func!(null as never, { pt: '20260409' })).rejects.toThrow( + 'SAKY API error 1108110565', + ); + }); +}); diff --git a/clis/saky/common.ts b/clis/saky/common.ts new file mode 100644 index 000000000..782ad38f4 --- /dev/null +++ b/clis/saky/common.ts @@ -0,0 +1,244 @@ +import { CliError, ConfigError, getErrorMessage } from '@jackwener/opencli/errors'; + +export const SAKY_SITE = 'saky'; +export const SAKY_DOMAIN = 'dataapi.weimeizi.com'; +export const SAKY_DEFAULT_BASE_URL = `https://${SAKY_DOMAIN}/warehouse`; +export const SAKY_DOC_TITLE = '产品标签信息导出 API 文档 V1.0.0'; +export const SAKY_DOC_SAMPLE_PT = '20260409'; + +export type SakyDatasetKey = 'tool' | 'electronics' | 'formula'; + +export interface SakyDatasetConfig { + command: SakyDatasetKey; + title: string; + description: string; + endpoint: string; + columns: string[]; + keyFields: string[]; +} + +export const SAKY_DATASETS: Record = { + tool: { + command: 'tool', + title: '工具类标签查询', + description: '查询工具类产品标签信息(如牙刷、牙线)', + endpoint: '/warehouse_ai/product_label_info_tool', + columns: ['id', 'cpmc', 'cpmcdh', 'cptm', 'cppl', 'sqrq', 'bbh', 'pt'], + keyFields: ['id', 'cpmc', 'cptm', 'cppl', 'pt'], + }, + electronics: { + command: 'electronics', + title: '电子类标签查询', + description: '查询电子类产品标签信息(如电动牙刷、冲牙器)', + endpoint: '/warehouse_ai/product_label_info_electronics', + columns: ['id', 'cpmc', 'cpmczj', 'cptm', 'cppl', 'xh', 'ys', 'pt'], + keyFields: ['id', 'cpmc', 'cptm', 'cppl', 'pt'], + }, + formula: { + command: 'formula', + title: '配方类标签查询', + description: '查询配方类产品标签和成分信息(如牙膏、漱口水)', + endpoint: '/warehouse_ai/product_label_info_formula', + columns: ['id', 'cpmc', 'cpmc1', 'cptxm', 'fl', 'cppl', 'jhl', 'pt'], + keyFields: ['id', 'cpmc', 'cptxm', 'cppl', 'pt'], + }, +}; + +interface SakyListResponse> { + errCode?: number; + errMsg?: string; + requestId?: string; + data?: { + totalNum?: number; + pageSize?: number; + pageNum?: number; + rows?: T[]; + }; +} + +function trimOrEmpty(value: unknown): string { + return value == null ? '' : String(value).trim(); +} + +function firstNonEmpty(...values: unknown[]): string { + for (const value of values) { + const text = trimOrEmpty(value); + if (text) return text; + } + return ''; +} + +function formatShanghaiDate(date: Date): string { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(date); + const year = parts.find((part) => part.type === 'year')?.value ?? ''; + const month = parts.find((part) => part.type === 'month')?.value ?? ''; + const day = parts.find((part) => part.type === 'day')?.value ?? ''; + return `${year}${month}${day}`; +} + +function defaultPt(): string { + return formatShanghaiDate(new Date(Date.now() - 24 * 60 * 60 * 1000)); +} + +function parsePositiveInt(value: unknown, name: string, fallback: number, max: number): number { + if (value === undefined || value === null || value === '') return fallback; + const numeric = Number(value); + if (!Number.isInteger(numeric) || numeric <= 0) { + throw new CliError('ARGUMENT', `${name} must be a positive integer.`, `Pass --${name} .`); + } + return Math.min(numeric, max); +} + +function parseBoolean(value: unknown, name: string, fallback: boolean): boolean { + if (value === undefined || value === null || value === '') return fallback; + if (typeof value === 'boolean') return value; + const normalized = trimOrEmpty(value).toLowerCase(); + if (['true', '1', 'yes', 'y'].includes(normalized)) return true; + if (['false', '0', 'no', 'n'].includes(normalized)) return false; + throw new CliError('ARGUMENT', `${name} must be true or false.`, `Pass --${name} true or --${name} false.`); +} + +function resolveAppCode(kwargs: Record): string { + const appCode = firstNonEmpty( + kwargs['app-code'], + kwargs.appCode, + process.env.SAKY_APPCODE, + process.env.SAKY_APP_CODE, + ); + if (!appCode) { + throw new ConfigError( + 'Missing SAKY APPCODE.', + 'Pass --app-code or set SAKY_APPCODE / SAKY_APP_CODE before running this command.', + ); + } + return appCode; +} + +function resolveBaseUrl(kwargs: Record): string { + const baseUrl = firstNonEmpty(kwargs['base-url'], kwargs.baseUrl, process.env.SAKY_BASE_URL, SAKY_DEFAULT_BASE_URL) + .replace(/\/+$/, ''); + if (!/^https?:\/\//i.test(baseUrl)) { + throw new ConfigError( + 'SAKY base URL must start with http:// or https://.', + `Current value: ${baseUrl}`, + ); + } + return baseUrl; +} + +export function buildSakyFooter(kwargs: Record): string { + const pt = firstNonEmpty(kwargs.pt, process.env.SAKY_PT, defaultPt()); + const pageNum = parsePositiveInt(kwargs['page-num'] ?? kwargs.pageNum, 'page-num', 1, 100000); + const pageSize = parsePositiveInt(kwargs['page-size'] ?? kwargs.pageSize, 'page-size', 10, 1000); + return `pt=${pt} · page=${pageNum} · size=${pageSize}`; +} + +export function sakyDocsRows(): Record[] { + return Object.values(SAKY_DATASETS).map((dataset) => ({ + command: `${SAKY_SITE}/${dataset.command}`, + endpoint: dataset.endpoint, + description: dataset.description, + sample_pt: SAKY_DOC_SAMPLE_PT, + key_fields: dataset.keyFields.join(', '), + })); +} + +export async function querySakyDataset( + datasetKey: SakyDatasetKey, + kwargs: Record, +): Promise[]> { + const dataset = SAKY_DATASETS[datasetKey]; + const pageNum = parsePositiveInt(kwargs['page-num'] ?? kwargs.pageNum, 'page-num', 1, 100000); + const pageSize = parsePositiveInt(kwargs['page-size'] ?? kwargs.pageSize, 'page-size', 10, 1000); + const pt = firstNonEmpty(kwargs.pt, process.env.SAKY_PT, defaultPt()); + if (!/^\d{8}$/.test(pt)) { + throw new CliError('ARGUMENT', 'pt must use YYYYMMDD format.', 'Pass --pt 20260409'); + } + const returnTotalNum = parseBoolean( + kwargs['return-total-num'] ?? kwargs.returnTotalNum, + 'return-total-num', + true, + ); + const appCode = resolveAppCode(kwargs); + const baseUrl = resolveBaseUrl(kwargs); + + const params = new URLSearchParams({ + pageNum: String(pageNum), + pageSize: String(pageSize), + pt, + returnTotalNum: String(returnTotalNum), + }); + const url = `${baseUrl}${dataset.endpoint}?${params.toString()}`; + + let response: Response; + try { + response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `APPCODE ${appCode}`, + 'Content-Type': 'application/json', + }, + }); + } catch (error: unknown) { + throw new CliError( + 'FETCH_ERROR', + `Unable to reach SAKY API: ${getErrorMessage(error)}`, + 'Check your network connection, VPN, or SAKY_BASE_URL and try again.', + ); + } + + const rawText = await response.text(); + let payload: SakyListResponse | string = rawText; + if (rawText) { + try { + payload = JSON.parse(rawText) as SakyListResponse; + } catch { + payload = rawText; + } + } + + if (!response.ok) { + const message = + payload && typeof payload === 'object' + ? trimOrEmpty(payload.errMsg) || `HTTP ${response.status}` + : trimOrEmpty(payload) || `HTTP ${response.status}`; + throw new CliError( + 'API_ERROR', + `SAKY API request failed: ${message}`, + 'Check the APPCODE, base URL, and whether the requested partition exists.', + ); + } + + if (!payload || typeof payload !== 'object') { + throw new CliError( + 'API_ERROR', + 'SAKY API returned a non-JSON response.', + 'Check the upstream gateway or try again later.', + ); + } + + if (payload.errCode !== 0) { + const hint = payload.errCode === 1108110565 + ? `数仓查询异常,优先检查 --pt 是否正确。文档样例日期是 ${SAKY_DOC_SAMPLE_PT}。` + : 'Check the APPCODE, requested partition, and upstream API status.'; + throw new CliError( + 'API_ERROR', + `SAKY API error ${String(payload.errCode ?? 'unknown')}: ${trimOrEmpty(payload.errMsg) || 'unknown error'}`, + hint, + ); + } + + const rows = Array.isArray(payload.data?.rows) ? payload.data?.rows ?? [] : []; + return rows.map((row) => ({ + ...(row as Record), + _requestId: payload.requestId ?? '', + _pageNum: payload.data?.pageNum ?? pageNum, + _pageSize: payload.data?.pageSize ?? pageSize, + _totalNum: payload.data?.totalNum ?? '', + })); +} diff --git a/clis/saky/docs.ts b/clis/saky/docs.ts new file mode 100644 index 000000000..ac8a7d7c2 --- /dev/null +++ b/clis/saky/docs.ts @@ -0,0 +1,21 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + SAKY_DEFAULT_BASE_URL, + SAKY_DOC_SAMPLE_PT, + SAKY_DOC_TITLE, + SAKY_SITE, + sakyDocsRows, +} from './common.js'; + +cli({ + site: SAKY_SITE, + name: 'docs', + description: 'Show SAKY product label API summary and command mapping', + strategy: Strategy.PUBLIC, + browser: false, + defaultFormat: 'table', + args: [], + columns: ['command', 'endpoint', 'description', 'sample_pt', 'key_fields'], + footerExtra: () => `${SAKY_DOC_TITLE} · base=${SAKY_DEFAULT_BASE_URL} · auth=APPCODE · sample_pt=${SAKY_DOC_SAMPLE_PT}`, + func: async () => sakyDocsRows(), +}); diff --git a/clis/saky/electronics.ts b/clis/saky/electronics.ts new file mode 100644 index 000000000..ee5eb2c25 --- /dev/null +++ b/clis/saky/electronics.ts @@ -0,0 +1,24 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { buildSakyFooter, querySakyDataset, SAKY_DATASETS, SAKY_DOMAIN, SAKY_SITE } from './common.js'; + +cli({ + site: SAKY_SITE, + name: 'electronics', + aliases: ['elec'], + description: 'Query electronics product label data from SAKY warehouse API', + domain: SAKY_DOMAIN, + strategy: Strategy.PUBLIC, + browser: false, + timeoutSeconds: 30, + args: [ + { name: 'page-num', type: 'int', default: 1, help: 'Page number' }, + { name: 'page-size', type: 'int', default: 10, help: 'Rows per page' }, + { name: 'pt', help: 'Partition date in YYYYMMDD; defaults to yesterday in Asia/Shanghai' }, + { name: 'return-total-num', type: 'bool', default: true, help: 'Return total count metadata' }, + { name: 'app-code', help: 'APPCODE for the API gateway; or set SAKY_APPCODE' }, + { name: 'base-url', help: 'Override API base URL; defaults to SAKY_BASE_URL or built-in base URL' }, + ], + columns: SAKY_DATASETS.electronics.columns, + footerExtra: buildSakyFooter, + func: async (_page, kwargs) => querySakyDataset('electronics', kwargs), +}); diff --git a/clis/saky/formula.ts b/clis/saky/formula.ts new file mode 100644 index 000000000..12e59305a --- /dev/null +++ b/clis/saky/formula.ts @@ -0,0 +1,23 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { buildSakyFooter, querySakyDataset, SAKY_DATASETS, SAKY_DOMAIN, SAKY_SITE } from './common.js'; + +cli({ + site: SAKY_SITE, + name: 'formula', + description: 'Query formula product label data from SAKY warehouse API', + domain: SAKY_DOMAIN, + strategy: Strategy.PUBLIC, + browser: false, + timeoutSeconds: 30, + args: [ + { name: 'page-num', type: 'int', default: 1, help: 'Page number' }, + { name: 'page-size', type: 'int', default: 10, help: 'Rows per page' }, + { name: 'pt', help: 'Partition date in YYYYMMDD; defaults to yesterday in Asia/Shanghai' }, + { name: 'return-total-num', type: 'bool', default: true, help: 'Return total count metadata' }, + { name: 'app-code', help: 'APPCODE for the API gateway; or set SAKY_APPCODE' }, + { name: 'base-url', help: 'Override API base URL; defaults to SAKY_BASE_URL or built-in base URL' }, + ], + columns: SAKY_DATASETS.formula.columns, + footerExtra: buildSakyFooter, + func: async (_page, kwargs) => querySakyDataset('formula', kwargs), +}); diff --git a/clis/saky/tool.ts b/clis/saky/tool.ts new file mode 100644 index 000000000..d5955263b --- /dev/null +++ b/clis/saky/tool.ts @@ -0,0 +1,23 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { buildSakyFooter, querySakyDataset, SAKY_DATASETS, SAKY_DOMAIN, SAKY_SITE } from './common.js'; + +cli({ + site: SAKY_SITE, + name: 'tool', + description: 'Query tool product label data from SAKY warehouse API', + domain: SAKY_DOMAIN, + strategy: Strategy.PUBLIC, + browser: false, + timeoutSeconds: 30, + args: [ + { name: 'page-num', type: 'int', default: 1, help: 'Page number' }, + { name: 'page-size', type: 'int', default: 10, help: 'Rows per page' }, + { name: 'pt', help: 'Partition date in YYYYMMDD; defaults to yesterday in Asia/Shanghai' }, + { name: 'return-total-num', type: 'bool', default: true, help: 'Return total count metadata' }, + { name: 'app-code', help: 'APPCODE for the API gateway; or set SAKY_APPCODE' }, + { name: 'base-url', help: 'Override API base URL; defaults to SAKY_BASE_URL or built-in base URL' }, + ], + columns: SAKY_DATASETS.tool.columns, + footerExtra: buildSakyFooter, + func: async (_page, kwargs) => querySakyDataset('tool', kwargs), +}); diff --git a/clis/shopee/product-shopdora-download.test.ts b/clis/shopee/product-shopdora-download.test.ts new file mode 100644 index 000000000..9046bb948 --- /dev/null +++ b/clis/shopee/product-shopdora-download.test.ts @@ -0,0 +1,149 @@ +import { pathToFileURL } from 'node:url'; +import { describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import type { IPage } from '@jackwener/opencli/types'; +import './product-shopdora-download.js'; + +const { + EXPORT_REVIEW_BUTTON_SELECTOR, + DETAIL_FILTER_INPUT_SELECTOR, + SECONDARY_FILTER_INPUT_SELECTOR, + CONFIRM_EXPORT_BUTTON_SELECTOR, + normalizeShopeeReviewUrl, + bindShopeeProductTab, + ensureShopeeProductPage, + buildEnsureCheckboxStateScript, + buildWaitForExportReviewReadyScript, +} = + await import('./product-shopdora-download.js').then((m) => (m as typeof import('./product-shopdora-download.js')).__test__); + +describe('shopee product-shopdora-download adapter', () => { + const command = getRegistry().get('shopee/product-shopdora-download'); + + it('registers the command with correct shape', () => { + expect(command).toBeDefined(); + expect(command!.site).toBe('shopee'); + expect(command!.name).toBe('product-shopdora-download'); + expect(command!.domain).toBe('shopee.sg'); + expect(command!.strategy).toBe('cookie'); + expect(command!.navigateBefore).toBe(false); + expect(command!.columns).toEqual(['status', 'message', 'local_url', 'local_path', 'product_url']); + expect(typeof command!.func).toBe('function'); + }); + + it('has url as a required positional arg', () => { + const urlArg = command!.args.find((arg) => arg.name === 'url'); + expect(urlArg).toBeDefined(); + expect(urlArg!.required).toBe(true); + expect(urlArg!.positional).toBe(true); + }); + + it('normalizes product urls', () => { + expect(normalizeShopeeReviewUrl('https://shopee.sg/item')).toBe('https://shopee.sg/item'); + expect(() => normalizeShopeeReviewUrl('')).toThrow('A Shopee product URL is required.'); + expect(() => normalizeShopeeReviewUrl('not-a-url')).toThrow('Shopee product-shopdora-download requires a valid absolute product URL.'); + }); + + it('builds DOM scripts around the recorded export workflow', () => { + expect(buildEnsureCheckboxStateScript(DETAIL_FILTER_INPUT_SELECTOR, true)).toContain(DETAIL_FILTER_INPUT_SELECTOR); + expect(buildEnsureCheckboxStateScript(SECONDARY_FILTER_INPUT_SELECTOR, false)).toContain('checkbox_not_found'); + expect(buildWaitForExportReviewReadyScript(30000, 1000)).toContain('.putButton .common-btn.en_common-btn'); + expect(buildWaitForExportReviewReadyScript(30000, 1000)).toContain('Export Review'); + }); + + it('binds to the matching existing browser tab using the shopee workspace', async () => { + const bindFn = vi.fn(async () => ({ tabId: 2 })); + + await expect( + bindShopeeProductTab( + 'https://shopee.sg/Jeep-EW121-True-Wireless-Bluetooth-5.4-Earbuds-i.1058254930.25483790400', + bindFn, + ), + ).resolves.toBe(true); + + expect(bindFn).toHaveBeenCalledWith('site:shopee', { + matchUrl: 'https://shopee.sg/Jeep-EW121-True-Wireless-Bluetooth-5.4-Earbuds-i.1058254930.25483790400', + }); + }); + + it('reuses the matched tab, clears localStorage, and reloads the product page', async () => { + const page = { + goto: vi.fn(async () => {}), + evaluate: vi.fn(async () => ({ ok: true, host: 'shopee.sg' })), + } as unknown as IPage; + const bindFn = vi.fn(async () => ({ tabId: 2 })); + + await expect( + ensureShopeeProductPage(page, 'https://shopee.sg/product-i.1.2', bindFn), + ).resolves.toBe(true); + + expect(page.goto).toHaveBeenNthCalledWith(1, 'https://shopee.sg', { waitUntil: 'load' }); + expect(page.evaluate).toHaveBeenCalledWith(expect.stringContaining('localStorage.clear()')); + expect(page.goto).toHaveBeenNthCalledWith(2, 'https://shopee.sg/product-i.1.2', { waitUntil: 'load' }); + }); + + it('navigates, downloads the file, and returns the local file url', async () => { + const downloadedFile = '/tmp/opencli-shopee-product-shopdora-download-test/reviews.csv'; + const goto = vi.fn>().mockResolvedValue(undefined); + const wait = vi.fn>().mockResolvedValue(undefined); + const click = vi.fn>().mockResolvedValue(undefined); + const scroll = vi.fn>().mockResolvedValue(undefined); + const evaluate = vi.fn>() + .mockResolvedValueOnce({ ok: true, host: 'shopee.sg' }) + .mockResolvedValue({ ok: true, text: 'Export Review' }); + const waitForDownload = vi.fn>>() + .mockResolvedValue({ filename: downloadedFile }); + + const page = { goto, wait, click, scroll, evaluate, waitForDownload } as unknown as IPage; + + const result = await command!.func!(page, { + url: 'https://shopee.sg/Jeep-EW121-True-Wireless-Bluetooth-5.4-Earbuds-i.1058254930.25483790400', + }); + + expect(goto).toHaveBeenNthCalledWith(1, 'https://shopee.sg', { waitUntil: 'load' }); + expect(goto).toHaveBeenNthCalledWith( + 2, + 'https://shopee.sg/Jeep-EW121-True-Wireless-Bluetooth-5.4-Earbuds-i.1058254930.25483790400', + { waitUntil: 'load' }, + ); + expect(evaluate).toHaveBeenNthCalledWith(1, expect.stringContaining('localStorage.clear()')); + expect(wait).toHaveBeenCalledWith({ selector: EXPORT_REVIEW_BUTTON_SELECTOR, timeout: 15 }); + expect(click).toHaveBeenCalledWith(EXPORT_REVIEW_BUTTON_SELECTOR); + expect(click).toHaveBeenCalledWith(CONFIRM_EXPORT_BUTTON_SELECTOR); + expect(scroll).toHaveBeenCalled(); + expect(evaluate).toHaveBeenCalledWith(expect.stringContaining(EXPORT_REVIEW_BUTTON_SELECTOR)); + expect(evaluate).toHaveBeenCalledWith(expect.stringContaining(DETAIL_FILTER_INPUT_SELECTOR)); + expect(evaluate).toHaveBeenCalledWith(expect.stringContaining(SECONDARY_FILTER_INPUT_SELECTOR)); + expect(evaluate).toHaveBeenCalledWith(expect.stringContaining(CONFIRM_EXPORT_BUTTON_SELECTOR)); + expect(evaluate).toHaveBeenCalledWith(expect.stringContaining('.putButton .common-btn.en_common-btn')); + expect(waitForDownload).toHaveBeenCalledWith({ + startedAfterMs: expect.any(Number), + timeoutMs: 30000, + }); + expect(result).toEqual([{ + status: 'success', + message: 'Downloaded Shopee product Shopdora export with the recorded good-detail filter.', + local_url: pathToFileURL(downloadedFile).href, + local_path: downloadedFile, + product_url: 'https://shopee.sg/Jeep-EW121-True-Wireless-Bluetooth-5.4-Earbuds-i.1058254930.25483790400', + }]); + }); + + it('falls back to clearing the target host and reopening the product page when no existing product tab is found', async () => { + const page = { + goto: vi.fn(async () => {}), + evaluate: vi.fn(async () => ({ ok: true, host: 'shopee.sg' })), + } as unknown as IPage; + const bindFn = vi.fn(async () => { + throw new Error('not found'); + }); + + await expect( + ensureShopeeProductPage(page, 'https://shopee.sg/product-i.1.2', bindFn), + ).resolves.toBe(false); + + expect(page.goto).toHaveBeenNthCalledWith(1, 'https://shopee.sg', { waitUntil: 'load' }); + expect(page.evaluate).toHaveBeenCalledWith(expect.stringContaining('localStorage.clear()')); + expect(page.goto).toHaveBeenNthCalledWith(2, 'https://shopee.sg/product-i.1.2', { waitUntil: 'load' }); + }); +}); diff --git a/clis/shopee/product-shopdora-download.ts b/clis/shopee/product-shopdora-download.ts new file mode 100644 index 000000000..6aedd1c45 --- /dev/null +++ b/clis/shopee/product-shopdora-download.ts @@ -0,0 +1,313 @@ +import { pathToFileURL } from 'node:url'; +import { + ArgumentError, + CommandExecutionError, + getErrorMessage, +} from '@jackwener/opencli/errors'; +import { bindCurrentTab } from '@jackwener/opencli/browser/daemon-client'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import type { IPage } from '@jackwener/opencli/types'; +import { clearLocalStorageForUrlHost, simulateHumanBehavior, waitRandomDuration } from './shared.js'; + +const EXPORT_REVIEW_BUTTON_SELECTOR = + 'div > div:nth-of-type(1) > div:nth-of-type(2) > div > div.common-btn.en_common-btn'; +const DETAIL_FILTER_LABEL_SELECTOR = + 'div > div:nth-of-type(4) > div:nth-of-type(2) > label > span.t-checkbox__input:nth-of-type(1)'; +const DETAIL_FILTER_INPUT_SELECTOR = + 'div > div:nth-of-type(4) > div:nth-of-type(2) > label > input.t-checkbox__former'; +const SECONDARY_FILTER_LABEL_SELECTOR = + 'div:nth-of-type(1) > div:nth-of-type(2) > span:nth-of-type(2) > label > span.t-checkbox__input:nth-of-type(1)'; +const SECONDARY_FILTER_INPUT_SELECTOR = + 'div:nth-of-type(1) > div:nth-of-type(2) > span:nth-of-type(2) > label > input.t-checkbox__former'; +const CONFIRM_EXPORT_BUTTON_SELECTOR = + 'div > div:nth-of-type(5) > div:nth-of-type(2) > button:nth-of-type(2)'; + +const SHOPEE_WORKSPACE = 'site:shopee'; + +type BindCurrentTabFn = ( + workspace: string, + opts?: { matchDomain?: string; matchPathPrefix?: string; matchUrl?: string }, +) => Promise; + +function normalizeShopeeReviewUrl(value: unknown): string { + const raw = String(value ?? '').trim(); + if (!raw) { + throw new ArgumentError('A Shopee product URL is required.'); + } + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new ArgumentError('Shopee product-shopdora-download requires a valid absolute product URL.'); + } + + if (!/^https?:$/.test(parsed.protocol)) { + throw new ArgumentError('Shopee product-shopdora-download only supports http(s) product URLs.'); + } + + return parsed.toString(); +} + +function buildEnsureCheckboxStateScript(selector: string, checked: boolean): string { + return ` + (() => { + const input = document.querySelector(${JSON.stringify(selector)}); + if (!(input instanceof HTMLInputElement)) { + return { ok: false, error: 'checkbox_not_found' }; + } + + if (input.checked === ${checked ? 'true' : 'false'}) { + return { ok: true, changed: false, checked: input.checked }; + } + + const label = input.closest('label'); + const clickable = label?.querySelector('span.t-checkbox__input') || label || input; + + if (!(clickable instanceof HTMLElement)) { + return { ok: false, error: 'checkbox_click_target_not_found' }; + } + + clickable.click(); + + return { + ok: input.checked === ${checked ? 'true' : 'false'}, + changed: true, + checked: input.checked, + }; + })() + `; +} + +async function bindShopeeProductTab( + productUrl: string, + bindFn: BindCurrentTabFn = bindCurrentTab, +): Promise { + try { + await bindFn(SHOPEE_WORKSPACE, { matchUrl: productUrl }); + return true; + } catch { + return false; + } +} + +async function ensureShopeeProductPage( + page: IPage, + productUrl: string, + bindFn: BindCurrentTabFn = bindCurrentTab, +): Promise { + const reusedExistingTab = await bindShopeeProductTab(productUrl, bindFn); + // await clearLocalStorageForUrlHost(page, productUrl); + await page.goto(productUrl, { waitUntil: 'load' }); + return reusedExistingTab; +} + +function buildWaitForExportReviewReadyScript(timeoutMs: number, pollIntervalMs: number): string { + return ` + new Promise((resolve, reject) => { + const timeout = ${timeoutMs}; + const pollInterval = ${pollIntervalMs}; + const selector = '.putButton .common-btn.en_common-btn'; + const normalizeText = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); + const startedAt = Date.now(); + let lastKnownText = ''; + + const readButtonState = () => { + const targets = Array.from(document.querySelectorAll(selector)); + const target = + targets.find((element) => { + const directText = Array.from(element.childNodes) + .filter((node) => node.nodeType === Node.TEXT_NODE) + .map((node) => node.textContent || '') + .join(' '); + return normalizeText(directText).includes('Export Review'); + }) || targets[0] || null; + + if (!target) return { found: false, text: '', done: false }; + + const buttonLabel = normalizeText( + Array.from(target.childNodes) + .filter((node) => node.nodeType === Node.TEXT_NODE) + .map((node) => node.textContent || '') + .join(' '), + ); + + return { + found: true, + text: buttonLabel, + done: buttonLabel === 'Export Review', + }; + }; + + const tick = () => { + const state = readButtonState(); + if (state.done) { + resolve({ ok: true, text: state.text || 'Export Review' }); + return; + } + + if (state.found) { + lastKnownText = state.text || ''; + } + + if (Date.now() - startedAt >= timeout) { + reject(new Error( + 'Timed out waiting for Export Review button text to reset. Last text: ' + + (lastKnownText || 'unknown'), + )); + return; + } + + setTimeout(tick, pollInterval); + }; + + setTimeout(tick, 2000); + }) + `; +} + +async function ensureCheckboxState(page: IPage, selector: string, checked: boolean, label: string): Promise { + const result = await page.evaluate(buildEnsureCheckboxStateScript(selector, checked)); + if (!result || typeof result !== 'object' || !(result as { ok?: boolean }).ok) { + throw new CommandExecutionError(`Shopee product-shopdora-download could not ${checked ? 'enable' : 'disable'} ${label}`); + } +} + +async function waitForExportReviewReady(page: IPage, timeoutMs = 30000, pollIntervalMs = 1000): Promise { + await page.evaluate(buildWaitForExportReviewReadyScript(timeoutMs, pollIntervalMs)); +} + +async function clickSelector(page: IPage, selector: string, label: string): Promise { + try { + await page.click(selector); + } catch (error) { + throw new CommandExecutionError( + `Shopee product-shopdora-download could not click ${label}`, + getErrorMessage(error), + ); + } +} + +async function applyCheckboxStep( + page: IPage, + labelSelector: string, + inputSelector: string, + checked: boolean, + label: string, +): Promise { + await page.wait({ selector: inputSelector, timeout: 10 }); + await simulateHumanBehavior(page, { + selector: labelSelector, + scrollRangePx: [30, 120], + preWaitRangeMs: [250, 700], + postWaitRangeMs: [150, 450], + }); + await clickSelector(page, labelSelector, `${label} label`); + await waitRandomDuration(page, [250, 650]); + await ensureCheckboxState(page, inputSelector, checked, label); + await waitRandomDuration(page, [250, 700]); +} + +cli({ + site: 'shopee', + name: 'product-shopdora-download', + description: 'Export Shopee product Shopdora data with the recorded good-detail review workflow', + domain: 'shopee.sg', + strategy: Strategy.COOKIE, + navigateBefore: false, + args: [ + { + name: 'url', + positional: true, + required: true, + help: 'Shopee product URL, e.g. https://shopee.sg/...-i.123.456', + }, + ], + columns: ['status', 'message', 'local_url', 'local_path', 'product_url'], + func: async (page, args) => { + if (!page) { + throw new CommandExecutionError( + 'Browser session required for shopee product-shopdora-download', + 'Run the command with the browser bridge connected', + ); + } + + const productUrl = normalizeShopeeReviewUrl(args.url); + if (typeof page.waitForDownload !== 'function') { + throw new CommandExecutionError( + 'Shopee product-shopdora-download requires browser download tracking support', + 'Reload the browser bridge extension/plugin to a build that supports download-wait.', + ); + } + + await ensureShopeeProductPage(page, productUrl); + await page.wait({ selector: EXPORT_REVIEW_BUTTON_SELECTOR, timeout: 15 }); + await simulateHumanBehavior(page, { + selector: EXPORT_REVIEW_BUTTON_SELECTOR, + scrollRangePx: [60, 180], + preWaitRangeMs: [500, 1200], + postWaitRangeMs: [300, 800], + allowReverseScroll: false, + }); + + await clickSelector(page, EXPORT_REVIEW_BUTTON_SELECTOR, 'Export Review'); + await waitRandomDuration(page, [900, 1600]); + + await applyCheckboxStep( + page, + DETAIL_FILTER_LABEL_SELECTOR, + DETAIL_FILTER_INPUT_SELECTOR, + true, + 'detail filter', + ); + await applyCheckboxStep( + page, + SECONDARY_FILTER_LABEL_SELECTOR, + SECONDARY_FILTER_INPUT_SELECTOR, + false, + 'secondary filter', + ); + + await page.wait({ selector: CONFIRM_EXPORT_BUTTON_SELECTOR, timeout: 10 }); + await simulateHumanBehavior(page, { + selector: CONFIRM_EXPORT_BUTTON_SELECTOR, + scrollRangePx: [20, 100], + preWaitRangeMs: [250, 700], + postWaitRangeMs: [200, 500], + }); + const downloadStartedAtMs = Date.now(); + await clickSelector(page, CONFIRM_EXPORT_BUTTON_SELECTOR, 'export confirm button'); + await waitForExportReviewReady(page); + + const download = await page.waitForDownload({ + startedAfterMs: downloadStartedAtMs, + timeoutMs: 30000, + }); + const localPath = String(download?.filename ?? '').trim(); + if (!localPath) { + throw new CommandExecutionError('Shopee product-shopdora-download finished without a local filename'); + } + + return [{ + status: 'success', + message: 'Downloaded Shopee product Shopdora export with the recorded good-detail filter.', + local_url: pathToFileURL(localPath).href, + local_path: localPath, + product_url: productUrl, + }]; + }, +}); + +export const __test__ = { + EXPORT_REVIEW_BUTTON_SELECTOR, + DETAIL_FILTER_LABEL_SELECTOR, + DETAIL_FILTER_INPUT_SELECTOR, + SECONDARY_FILTER_LABEL_SELECTOR, + SECONDARY_FILTER_INPUT_SELECTOR, + CONFIRM_EXPORT_BUTTON_SELECTOR, + normalizeShopeeReviewUrl, + bindShopeeProductTab, + ensureShopeeProductPage, + buildEnsureCheckboxStateScript, + buildWaitForExportReviewReadyScript, +}; diff --git a/clis/shopee/product.test.ts b/clis/shopee/product.test.ts new file mode 100644 index 000000000..943445303 --- /dev/null +++ b/clis/shopee/product.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './product.js'; + +const { + PRODUCT_COLUMNS, + PRODUCT_FIELDS, + mergeProductDetails, + hasMeaningfulProductData, + firstUrlFromSrcset, + pickImageUrlFromAttributes, + bindShopeeProductTab, + ensureShopeeProductPage, +} = + await import('./product.js').then((m) => (m as typeof import('./product.js')).__test__); + +describe('shopee product adapter', () => { + const command = getRegistry().get('shopee/product'); + + it('registers the command with correct shape', () => { + expect(command).toBeDefined(); + expect(command!.site).toBe('shopee'); + expect(command!.name).toBe('product'); + expect(command!.domain).toBe('shopee.sg'); + expect(command!.strategy).toBe('cookie'); + expect(command!.navigateBefore).toBe(false); + expect(typeof command!.func).toBe('function'); + }); + + it('has url as a required positional arg', () => { + const urlArg = command!.args.find((arg) => arg.name === 'url'); + expect(urlArg).toBeDefined(); + expect(urlArg!.required).toBe(true); + expect(urlArg!.positional).toBe(true); + }); + + it('includes key product fields in the output columns', () => { + expect(PRODUCT_COLUMNS).toEqual( + expect.arrayContaining([ + 'product_url', + 'title', + 'rating_score', + 'current_price_range', + 'shopee_price', + 'shopdora_price', + 'main_image_url', + 'video_url', + 'thumbnail_url', + 'attr_options', + 'spec_options', + 'seller_name', + 'shop_name', + 'shop_url', + 'shop_product_list_url', + 'stock', + ]), + ); + expect(command!.columns).toEqual(expect.arrayContaining(PRODUCT_COLUMNS)); + }); + + it('marks structured template fields with list metadata', () => { + const videoField = PRODUCT_FIELDS.find((field) => field.name === 'video_url'); + const thumbnailField = PRODUCT_FIELDS.find((field) => field.name === 'thumbnail_url'); + const attrOptionsField = PRODUCT_FIELDS.find((field) => field.name === 'attr_options'); + const specOptionsField = PRODUCT_FIELDS.find((field) => field.name === 'spec_options'); + + expect(videoField).toMatchObject({ + type: 'list', + fields: [ + { name: 'video_url', type: 'attribute', attribute: 'src', transform: 'absolute_url' }, + ], + }); + expect(thumbnailField).toMatchObject({ + type: 'list', + fields: [{ name: 'thumbnail_url', type: 'attribute', attribute: 'src', transform: 'image_src' }], + }); + expect(attrOptionsField).toMatchObject({ + type: 'list', + fields: expect.arrayContaining([ + expect.objectContaining({ name: 'title', type: 'text' }), + expect.objectContaining({ name: 'image_url', type: 'attribute', attribute: 'src', transform: 'image_src' }), + expect.objectContaining({ name: 'is_selected', transform: 'selected_class' }), + ]), + }); + expect(specOptionsField).toMatchObject({ + type: 'list', + fields: expect.arrayContaining([ + expect.objectContaining({ name: 'title', type: 'text' }), + expect.objectContaining({ name: 'is_selected', transform: 'selected_class' }), + ]), + }); + }); +}); + +describe('shopee attr option image helpers', () => { + it('parses the first url from srcset values', () => { + expect( + firstUrlFromSrcset( + 'https://down-sg.img.susercontent.com/file/sg-11134207-7rdwc-mcj4nu2ezjl22d@resize_w24_nl.webp 1x, https://down-sg.img.susercontent.com/file/sg-11134207-7rdwc-mcj4nu2ezjl22d@resize_w48_nl.webp 2x', + ), + ).toBe('https://down-sg.img.susercontent.com/file/sg-11134207-7rdwc-mcj4nu2ezjl22d@resize_w24_nl.webp'); + }); + + it('prefers the direct img src for shopee picture button attrs', () => { + expect( + pickImageUrlFromAttributes({ + src: 'https://down-sg.img.susercontent.com/file/sg-11134207-7rdwc-mcj4nu2ezjl22d', + srcset: + 'https://down-sg.img.susercontent.com/file/sg-11134207-7rdwc-mcj4nu2ezjl22d@resize_w24_nl 1x, https://down-sg.img.susercontent.com/file/sg-11134207-7rdwc-mcj4nu2ezjl22d@resize_w48_nl 2x', + }), + ).toBe('https://down-sg.img.susercontent.com/file/sg-11134207-7rdwc-mcj4nu2ezjl22d'); + }); +}); + +describe('mergeProductDetails', () => { + it('fills only missing fields from a later extraction pass', () => { + expect( + mergeProductDetails( + { title: 'Product A', seller_name: '', stock: '' }, + { title: 'Product B', seller_name: 'Shop 1', stock: '99' }, + ), + ).toEqual({ + title: 'Product A', + seller_name: 'Shop 1', + stock: '99', + }); + }); +}); + +describe('hasMeaningfulProductData', () => { + it('returns false for empty extraction rows', () => { + expect(hasMeaningfulProductData({ title: '', seller_name: '' })).toBe(false); + }); + + it('returns true once any mapped product field has content', () => { + expect(hasMeaningfulProductData({ title: 'Wireless Earbuds' })).toBe(true); + }); +}); + +describe('bindShopeeProductTab', () => { + it('binds to the matching existing browser tab using the shopee workspace', async () => { + const bindFn = vi.fn(async () => ({ tabId: 2 })); + + await expect( + bindShopeeProductTab( + 'https://shopee.sg/Jeep-EW121-True-Wireless-Bluetooth-5.4-Earbuds-i.1058254930.25483790400', + bindFn, + ), + ).resolves.toBe(true); + + expect(bindFn).toHaveBeenCalledWith('site:shopee', { + matchUrl: 'https://shopee.sg/Jeep-EW121-True-Wireless-Bluetooth-5.4-Earbuds-i.1058254930.25483790400', + }); + }); + + it('returns false when no existing browser tab matches the product url', async () => { + const bindFn = vi.fn(async () => { + throw new Error('No visible tab matching target'); + }); + + await expect( + bindShopeeProductTab('https://shopee.sg/product-i.1.2', bindFn), + ).resolves.toBe(false); + }); +}); + +describe('ensureShopeeProductPage', () => { + it('reuses the matched tab, clears localStorage, and reloads the product page', async () => { + const page = { + goto: vi.fn(async () => {}), + evaluate: vi.fn(async () => ({ ok: true, host: 'shopee.sg' })), + } as unknown as import('@jackwener/opencli/types').IPage; + const bindFn = vi.fn(async () => ({ tabId: 2 })); + + await expect( + ensureShopeeProductPage(page, 'https://shopee.sg/product-i.1.2', bindFn), + ).resolves.toBe(true); + + expect(page.goto).toHaveBeenNthCalledWith(1, 'https://shopee.sg', { waitUntil: 'load' }); + expect(page.evaluate).toHaveBeenCalledWith(expect.stringContaining('localStorage.clear()')); + expect(page.goto).toHaveBeenNthCalledWith(2, 'https://shopee.sg/product-i.1.2', { waitUntil: 'load' }); + }); + + it('falls back to clearing the target host and opening the product url when no existing tab is found', async () => { + const page = { + goto: vi.fn(async () => {}), + evaluate: vi.fn(async () => ({ ok: true, host: 'shopee.sg' })), + } as unknown as import('@jackwener/opencli/types').IPage; + const bindFn = vi.fn(async () => { + throw new Error('not found'); + }); + + await expect( + ensureShopeeProductPage(page, 'https://shopee.sg/product-i.1.2', bindFn), + ).resolves.toBe(false); + + expect(page.goto).toHaveBeenNthCalledWith(1, 'https://shopee.sg', { waitUntil: 'load' }); + expect(page.evaluate).toHaveBeenCalledWith(expect.stringContaining('localStorage.clear()')); + expect(page.goto).toHaveBeenNthCalledWith(2, 'https://shopee.sg/product-i.1.2', { waitUntil: 'load' }); + }); +}); diff --git a/clis/shopee/product.ts b/clis/shopee/product.ts new file mode 100644 index 000000000..70cedaaaa --- /dev/null +++ b/clis/shopee/product.ts @@ -0,0 +1,456 @@ +import { + CommandExecutionError, + EmptyResultError, +} from '@jackwener/opencli/errors'; +import { bindCurrentTab } from '@jackwener/opencli/browser/daemon-client'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import type { IPage } from '@jackwener/opencli/types'; +import { clearLocalStorageForUrlHost, simulateHumanBehavior } from './shared.js'; + +type ShopeeField = { + name: string; + selector: string; + type?: 'text' | 'attribute' | 'list'; + attribute?: string; + fields?: ShopeeField[]; + transform?: 'absolute_url' | 'selected_class' | 'image_src'; +}; + +const DIRECT_IMAGE_ATTRIBUTES = [ + 'src', + 'data-src', + 'data-lazy-src', + 'data-original', + 'data-img-src', +] as const; + +const SRCSET_IMAGE_ATTRIBUTES = [ + 'srcset', + 'data-srcset', + 'data-lazy-srcset', +] as const; + +function firstUrlFromSrcset(value: unknown): string { + const raw = String(value ?? '').trim(); + if (!raw) return ''; + const candidate = raw + .split(',') + .map((part) => part.trim()) + .find(Boolean); + if (!candidate) return ''; + return candidate.split(/\s+/)[0]?.trim() ?? ''; +} + +function pickImageUrlFromAttributes( + attributes: Partial>, +): string { + for (const key of DIRECT_IMAGE_ATTRIBUTES) { + const value = String(attributes[key] ?? '').trim(); + if (value) return value; + } + + for (const key of SRCSET_IMAGE_ATTRIBUTES) { + const value = firstUrlFromSrcset(attributes[key]); + if (value) return value; + } + + return ''; +} + +const PRODUCT_FIELDS: ShopeeField[] = [ + { name: 'title', selector: 'h1.vR6K3w > span' }, + { name: 'rating_score', selector: 'div.F9RHbS.dQEiAI' }, + { name: 'rating_count_text', selector: 'button.flex.e2p50f:nth-of-type(2) > .F9RHbS' }, + { name: 'sold_count_text', selector: '.aleSBU > .AcmPRb' }, + { name: 'current_price_range', selector: '.shopdoraPirceList span' }, + { name: 'shopee_price', selector: '.jRlVo0 .IZPeQz.B67UQ0' }, + { name: 'shopdora_price', selector: '.shopdoraPirceList span' }, + { name: 'original_price', selector: '.ZA5sW5' }, + { name: 'discount_percentage', selector: '.vms4_3' }, + { + name: 'main_image_url', + selector: '.xxW0BG .HJ5l1F .center.Oj2Oo7 > img.rWN4DK, .xxW0BG .HJ5l1F .center.Oj2Oo7 > img, .UdI7e2 picture img.fMm3P2, .UdI7e2 picture img', + type: 'attribute', + attribute: 'src', + transform: 'image_src', + }, + { + name: 'video_url', + selector: '.xxW0BG .HJ5l1F .center.Oj2Oo7 video source[src], .xxW0BG .HJ5l1F .center.Oj2Oo7 video[src], .UdI7e2 video source[src], .UdI7e2 video[src], .airUhU .UBG7wZ .YM40Nc video source[src], .airUhU .UBG7wZ .YM40Nc video[src]', + type: 'list', + fields: [ + { + name: 'video_url', + selector: '', + type: 'attribute', + attribute: 'src', + transform: 'absolute_url', + }, + ], + }, + { + name: 'thumbnail_url', + selector: '.airUhU .UBG7wZ .YM40Nc picture img.raRnQV, .airUhU .UBG7wZ .YM40Nc picture img', + type: 'list', + fields: [ + { + name: 'thumbnail_url', + selector: '', + type: 'attribute', + attribute: 'src', + transform: 'image_src', + }, + ], + }, + { name: 'first_variant_name', selector: '.j7HL5Q button:first-of-type span.ZivAAW' }, + { + name: 'first_variant_image_url', + selector: '.j7HL5Q button:first-of-type picture, .j7HL5Q button:first-of-type img', + type: 'attribute', + attribute: 'src', + transform: 'image_src', + }, + { + name: 'attr_options', + selector: '.j7HL5Q button:has(img)', + type: 'list', + fields: [ + { name: 'name', selector: '.ZivAAW', type: 'text' }, + { name: 'title', selector: '.ZivAAW', type: 'text' }, + { name: 'label', selector: '', type: 'attribute', attribute: 'aria-label' }, + { name: 'image_url', selector: 'picture, img', type: 'attribute', attribute: 'src', transform: 'image_src' }, + { name: 'is_disabled', selector: '', type: 'attribute', attribute: 'aria-disabled' }, + { + name: 'is_selected', + selector: '', + type: 'attribute', + attribute: 'class', + transform: 'selected_class', + }, + ], + }, + { + name: 'spec_options', + selector: '.j7HL5Q button:not(:has(img))', + type: 'list', + fields: [ + { name: 'name', selector: '.ZivAAW', type: 'text' }, + { name: 'title', selector: '.ZivAAW', type: 'text' }, + { name: 'label', selector: '', type: 'attribute', attribute: 'aria-label' }, + { name: 'is_disabled', selector: '', type: 'attribute', attribute: 'aria-disabled' }, + { + name: 'is_selected', + selector: '', + type: 'attribute', + attribute: 'class', + transform: 'selected_class', + }, + ], + }, + { name: 'first_sku_price', selector: '.t-table__body tr:first-child td:nth-child(2) p' }, + { + name: 'product_title', + selector: '.detail-info > .detail-info-list:nth-child(1) > .detail-info-item:nth-child(2) .detail-info-item-main, .detail-info > .detail-info-list:nth-child(1) > .detail-info-item:nth-child(2) .item-main.cursor, .detail-info > .detail-info-list:nth-child(1) > .detail-info-item:nth-child(2) .item-main', + }, + + { name: 'product_id', selector: '.detail-info-list:nth-of-type(1) .detail-info-item:nth-of-type(1) .item-main' }, + { name: 'seller_name', selector: '.detail-info-list:nth-of-type(1) .detail-info-item:nth-of-type(2) .item-main' }, + { name: 'seller_source', selector: '.detail-info-list:nth-of-type(1) .detail-info-item:nth-of-type(2) .sellerSourceTips' }, + { name: 'brand_name', selector: '.detail-info-list:nth-of-type(1) .detail-info-item:nth-of-type(3) .item-main' }, + { name: 'category', selector: '.detail-info-list:nth-of-type(2) .detail-info-item:nth-of-type(1) .item-main' }, + { name: 'category_sales_rank', selector: '.detail-info-list:nth-of-type(2) .detail-info-item:nth-of-type(1) .tem-main' }, + + { name: 'listing_date', selector: '.detail-info-list:nth-of-type(2) .detail-info-item:nth-of-type(2) .item-main' }, + { + name: 'sales_1d_7d', + selector: '.detail-info > .detail-info-list:nth-child(4) > .detail-info-item:nth-child(1) .detail-info-item-main, .detail-info > .detail-info-list:nth-child(4) > .detail-info-item:nth-child(1) .item-main', + }, + { name: 'sales_growth_30d', selector: '.detail-info-list:nth-of-type(3) .detail-info-item:nth-of-type(2) .item-main' }, + { name: 'sales_30d', selector: '.detail-info-list:nth-of-type(4) .detail-info-item:nth-of-type(1) .item-main' }, + { name: 'gmv_30d', selector: '.detail-info-list:nth-of-type(4) .detail-info-item:nth-of-type(2) .item-main' }, + { name: 'total_sales', selector: '.detail-info-list:nth-of-type(5) .detail-info-item:nth-of-type(1) .item-main' }, + { name: 'total_gmv', selector: '.detail-info-list:nth-of-type(5) .detail-info-item:nth-of-type(2) .item-main' }, + { name: 'stock', selector: '.detail-info-list:nth-of-type(6) .item-main' }, + { name: 'shop_name', selector: '#sll2-pdp-product-shop .fV3TIn' }, + { + name: 'shop_url', + selector: '#sll2-pdp-product-shop a.lG5Xxv', + type: 'attribute', + attribute: 'href', + transform: 'absolute_url', + }, + { + name: 'shop_logo_url', + selector: '#sll2-pdp-product-shop .uLQaPg picture img.Qm507c, #sll2-pdp-product-shop .uLQaPg picture img', + type: 'attribute', + attribute: 'src', + transform: 'image_src', + }, + { name: 'shop_last_active', selector: '#sll2-pdp-product-shop .mMlpiZ .Fsv0YO' }, + { name: 'shop_rating_count', selector: '#sll2-pdp-product-shop .NGzCXN > :nth-child(1) .Cs6w3G' }, + { name: 'shop_chat_response_rate', selector: '#sll2-pdp-product-shop .NGzCXN > :nth-child(2) .Cs6w3G' }, + { name: 'shop_joined_time', selector: '#sll2-pdp-product-shop .NGzCXN > :nth-child(3) .Cs6w3G' }, + { name: 'shop_product_count', selector: '#sll2-pdp-product-shop .NGzCXN > :nth-child(4) .Cs6w3G' }, + { + name: 'shop_product_list_url', + selector: '#sll2-pdp-product-shop .NGzCXN a.aArpoe', + type: 'attribute', + attribute: 'href', + transform: 'absolute_url', + }, + { name: 'shop_response_speed', selector: '#sll2-pdp-product-shop .NGzCXN > :nth-child(5) .Cs6w3G' }, + { name: 'shop_follower_count', selector: '#sll2-pdp-product-shop .NGzCXN > :nth-child(6) .Cs6w3G' }, +]; + +const PRODUCT_COLUMNS = [ + 'product_url', + ...PRODUCT_FIELDS.map((field) => field.name), +]; + +const SHOPEE_WORKSPACE = 'site:shopee'; + +type BindCurrentTabFn = ( + workspace: string, + opts?: { matchDomain?: string; matchPathPrefix?: string; matchUrl?: string }, +) => Promise; + +function mergeProductDetails( + current: Record, + incoming: Record, +): Record { + const merged = { ...current }; + for (const [key, value] of Object.entries(incoming)) { + const nextValue = String(value ?? '').trim(); + const currentValue = String(merged[key] ?? '').trim(); + if (!currentValue && nextValue) { + merged[key] = value; + } + } + return merged; +} + +function hasMeaningfulProductData(row: Record): boolean { + return PRODUCT_FIELDS.some((field) => String(row[field.name] ?? '').trim() !== ''); +} + +async function bindShopeeProductTab( + productUrl: string, + bindFn: BindCurrentTabFn = bindCurrentTab, +): Promise { + try { + await bindFn(SHOPEE_WORKSPACE, { matchUrl: productUrl }); + return true; + } catch { + return false; + } +} + +async function ensureShopeeProductPage( + page: IPage, + productUrl: string, + bindFn: BindCurrentTabFn = bindCurrentTab, +): Promise { + const reusedExistingTab = await bindShopeeProductTab(productUrl, bindFn); + await clearLocalStorageForUrlHost(page, productUrl); + await page.goto(productUrl, { waitUntil: 'load' }); + return reusedExistingTab; +} + +async function extractProductDetails(page: IPage, productUrl: string): Promise> { + const baseOrigin = new URL(productUrl).origin; + const script = ` + (() => { + const fields = ${JSON.stringify(PRODUCT_FIELDS)}; + const baseOrigin = ${JSON.stringify(baseOrigin)}; + const normalizeText = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim(); + const toScalar = (value) => { + if (value === null || value === undefined) return ''; + return typeof value === 'string' ? value.trim() : String(value); + }; + const applyTransform = (value, field) => { + if (field.transform === 'absolute_url') { + const text = toScalar(value); + return text.startsWith('/') ? baseOrigin + text : text; + } + if (field.transform === 'image_src') { + return toScalar(value); + } + if (field.transform === 'selected_class') { + return /selection-box-selected/.test(toScalar(value)) ? 'true' : 'false'; + } + return value; + }; + const firstUrlFromSrcset = ${firstUrlFromSrcset.toString()}; + const pickImageUrlFromAttributes = ${pickImageUrlFromAttributes.toString()}; + const extractImageUrl = (target) => { + const candidates = [target]; + if (typeof target?.querySelector === 'function') { + candidates.push( + target.querySelector('img'), + target.querySelector('picture img'), + target.querySelector('source'), + target.querySelector('picture source'), + ); + } + + for (const node of candidates) { + if (!(node instanceof Element)) continue; + const attrs = { + src: node.getAttribute('src') || '', + 'data-src': node.getAttribute('data-src') || '', + 'data-lazy-src': node.getAttribute('data-lazy-src') || '', + 'data-original': node.getAttribute('data-original') || '', + 'data-img-src': node.getAttribute('data-img-src') || '', + srcset: node.getAttribute('srcset') || '', + 'data-srcset': node.getAttribute('data-srcset') || '', + 'data-lazy-srcset': node.getAttribute('data-lazy-srcset') || '', + }; + const value = pickImageUrlFromAttributes(attrs); + if (value) return value; + } + + return ''; + }; + const isMeaningfulValue = (value) => { + if (Array.isArray(value)) return value.some(isMeaningfulValue); + if (value && typeof value === 'object') { + return Object.values(value).some(isMeaningfulValue); + } + return toScalar(value) !== ''; + }; + const extractFieldValue = (scope, field) => { + const selector = typeof field.selector === 'string' ? field.selector.trim() : ''; + + if (field.type === 'list') { + if (!selector) return ''; + const itemFields = Array.isArray(field.fields) ? field.fields : []; + const items = Array.from(scope.querySelectorAll(selector)) + .map((node) => { + const item = {}; + for (const childField of itemFields) { + item[childField.name] = extractFieldValue(node, childField); + } + return item; + }) + .filter(isMeaningfulValue); + + if (!items.length) return ''; + if (itemFields.length === 1 && itemFields[0]?.name === field.name) { + return JSON.stringify(items.map((item) => item[field.name] ?? '')); + } + return JSON.stringify(items); + } + + const target = selector ? scope.querySelector(selector) : scope; + if (!target) return ''; + + if (field.type === 'attribute') { + if (field.transform === 'image_src') { + return extractImageUrl(target); + } + const attrName = typeof field.attribute === 'string' && field.attribute.trim() + ? field.attribute.trim() + : target instanceof HTMLAnchorElement + ? 'href' + : 'src'; + return toScalar(applyTransform(target.getAttribute(attrName) || '', field)); + } + + return normalizeText(applyTransform(target.textContent || '', field)); + }; + const row = {}; + + for (const field of fields) { + row[field.name] = extractFieldValue(document, field); + } + + return row; + })() + `; + + + let merged: Record = { product_url: productUrl }; + let lastSnapshot = ''; + + for (let round = 0; round < 5; round += 1) { + if (round === 0) { + await simulateHumanBehavior(page, { + selector: 'h1.vR6K3w > span, .shopdoraPirceList span', + scrollRangePx: [80, 220], + preWaitRangeMs: [350, 900], + postWaitRangeMs: [300, 800], + }); + } + + const batch = await page.evaluate(script); + const nextRow = typeof batch === 'object' && batch ? batch as Record : {}; + merged = mergeProductDetails(merged, nextRow); + + const snapshot = JSON.stringify(merged); + if (hasMeaningfulProductData(merged) && snapshot === lastSnapshot) { + return merged; + } + lastSnapshot = snapshot; + + if (round < 4) { + await simulateHumanBehavior(page, { + selector: round < 2 ? '.j7HL5Q button, .detail-info .item-main' : '#sll2-pdp-product-shop', + scrollRangePx: [900, 1400], + preWaitRangeMs: [220, 700], + postWaitRangeMs: [450, 1200], + }); + } + } + + return merged; +} + +cli({ + site: 'shopee', + name: 'product', + description: 'Get Shopee product details from a product URL', + domain: 'shopee.sg', + strategy: Strategy.COOKIE, + navigateBefore: false, + args: [ + { + name: 'url', + positional: true, + required: true, + help: 'Shopee product URL, e.g. https://shopee.sg/...-i.123.456', + }, + ], + columns: PRODUCT_COLUMNS, + func: async (page, args) => { + if (!page) { + throw new CommandExecutionError( + 'Browser session required for shopee product', + 'Run the command with the browser bridge connected', + ); + } + + const productUrl = args.url; + await ensureShopeeProductPage(page, productUrl); + const row = await extractProductDetails(page, productUrl); + + if (!hasMeaningfulProductData(row)) { + throw new EmptyResultError( + 'shopee product', + 'The product page did not expose any data. Check that the URL is reachable and the browser is logged into Shopee if needed.', + ); + } + + return [row]; + }, +}); + +export const __test__ = { + PRODUCT_COLUMNS, + PRODUCT_FIELDS, + mergeProductDetails, + hasMeaningfulProductData, + firstUrlFromSrcset, + pickImageUrlFromAttributes, + bindShopeeProductTab, + ensureShopeeProductPage, +}; diff --git a/clis/shopee/shared.test.ts b/clis/shopee/shared.test.ts new file mode 100644 index 000000000..8505cc5e3 --- /dev/null +++ b/clis/shopee/shared.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { IPage } from '@jackwener/opencli/types'; +import { __test__ } from './shared.js'; + +describe('shopee shared humanization helpers', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('builds a pointer simulation script for the target selector', () => { + const script = __test__.buildHumanPointerScript('.target-button'); + expect(script).toContain('.target-button'); + expect(script).toContain('mousemove'); + expect(script).toContain('scrollIntoView'); + }); + + it('builds a localStorage clearing script scoped to the target host', () => { + const script = __test__.buildClearLocalStorageScript('shopee.sg'); + expect(script).toContain('shopee.sg'); + expect(script).toContain('localStorage.clear()'); + expect(script).toContain('host_mismatch'); + }); + + it('waits for a randomized duration within the provided range', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + const wait = vi.fn>().mockResolvedValue(undefined); + const page = { wait } as unknown as IPage; + + const seconds = await __test__.waitRandomDuration(page, [200, 600]); + + expect(seconds).toBe(0.8); + expect(wait).toHaveBeenCalledWith(0.8); + }); + + it('simulates lightweight human behavior around a selector', async () => { + vi.spyOn(Math, 'random') + .mockReturnValueOnce(0.5) + .mockReturnValueOnce(0.4) + .mockReturnValueOnce(0.2) + .mockReturnValueOnce(0.3) + .mockReturnValueOnce(0.5); + + const page = { + wait: vi.fn>().mockResolvedValue(undefined), + scroll: vi.fn>().mockResolvedValue(undefined), + evaluate: vi.fn>().mockResolvedValue({ ok: true }), + } as unknown as IPage; + + await __test__.simulateHumanBehavior(page, { + selector: '.target-button', + preWaitRangeMs: [200, 600], + postWaitRangeMs: [100, 300], + scrollRangePx: [100, 300], + }); + + expect(page.wait).toHaveBeenCalledTimes(2); + expect(page.wait).toHaveBeenNthCalledWith(1, 0.8); + expect(page.wait).toHaveBeenNthCalledWith(2, 0.4); + expect(page.scroll).toHaveBeenCalledWith('down', 180); + expect(page.scroll).toHaveBeenCalledWith('up', 58); + expect(page.evaluate).toHaveBeenCalledWith(expect.stringContaining('.target-button')); + }); + + it('navigates to the target origin before clearing localStorage for that host', async () => { + const goto = vi.fn>().mockResolvedValue(undefined); + const evaluate = vi.fn>().mockResolvedValue({ ok: true, host: 'shopee.sg' }); + const page = { goto, evaluate } as unknown as IPage; + + await __test__.clearLocalStorageForUrlHost(page, 'https://shopee.sg/product-i.1.2'); + + expect(goto).toHaveBeenCalledWith('https://shopee.sg', { waitUntil: 'load' }); + expect(evaluate).toHaveBeenCalledWith(expect.stringContaining('localStorage.clear()')); + expect(evaluate).toHaveBeenCalledWith(expect.stringContaining('shopee.sg')); + }); +}); diff --git a/clis/shopee/shared.ts b/clis/shopee/shared.ts new file mode 100644 index 000000000..db7ff1bfd --- /dev/null +++ b/clis/shopee/shared.ts @@ -0,0 +1,169 @@ +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import type { IPage } from '@jackwener/opencli/types'; + +type HumanBehaviorOptions = { + selector?: string; + preWaitRangeMs?: readonly [number, number]; + postWaitRangeMs?: readonly [number, number]; + scrollRangePx?: readonly [number, number]; + allowReverseScroll?: boolean; +}; + +const RANDOM_DELAY_MULTIPLIER = 2; + +function normalizeRange(range: readonly [number, number]): [number, number] { + const [rawMin, rawMax] = range; + const min = Number.isFinite(rawMin) ? rawMin : 0; + const max = Number.isFinite(rawMax) ? rawMax : min; + return min <= max ? [min, max] : [max, min]; +} + +function randomInRange(range: readonly [number, number]): number { + const [min, max] = normalizeRange(range); + if (min === max) return min; + return min + Math.random() * (max - min); +} + +function millisecondsToSeconds(value: number): number { + return Math.max(0, Number((value / 1000).toFixed(3))); +} + +export async function waitRandomDuration( + page: IPage, + range: readonly [number, number], +): Promise { + const seconds = millisecondsToSeconds(randomInRange(range) * RANDOM_DELAY_MULTIPLIER); + await page.wait(seconds); + return seconds; +} + +export function buildClearLocalStorageScript(host: string): string { + return ` + (() => { + if (window.location.host !== ${JSON.stringify(host)}) { + return { + ok: false, + reason: 'host_mismatch', + expectedHost: ${JSON.stringify(host)}, + actualHost: window.location.host, + }; + } + + try { + window.localStorage.clear(); + return { ok: true, host: window.location.host }; + } catch (error) { + return { + ok: false, + reason: 'clear_failed', + message: error instanceof Error ? error.message : String(error ?? ''), + }; + } + })() + `; +} + +export function buildHumanPointerScript(selector: string): string { + return ` + (() => { + let target = null; + try { + target = document.querySelector(${JSON.stringify(selector)}); + } catch { + return { ok: false, reason: 'invalid_selector' }; + } + + if (!(target instanceof HTMLElement)) { + return { ok: false, reason: 'not_found' }; + } + + target.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' }); + const rect = target.getBoundingClientRect(); + const relativeX = 0.25 + Math.random() * 0.5; + const relativeY = 0.25 + Math.random() * 0.5; + const clientX = Math.round(rect.left + Math.max(1, rect.width * relativeX)); + const clientY = Math.round(rect.top + Math.max(1, rect.height * relativeY)); + + for (const type of ['mousemove', 'mouseenter', 'mouseover']) { + try { + target.dispatchEvent(new MouseEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + clientX, + clientY, + view: window, + })); + } catch {} + } + + try { + target.focus({ preventScroll: true }); + } catch { + try { + target.focus(); + } catch {} + } + + return { ok: true, tag: target.tagName.toLowerCase() }; + })() + `; +} + +async function safeScroll(page: IPage, direction: 'up' | 'down', range: readonly [number, number]): Promise { + try { + await page.scroll(direction, Math.round(randomInRange(range))); + } catch { + // Best-effort humanization should not block the primary workflow. + } +} + +export async function simulateHumanBehavior( + page: IPage, + { + selector, + preWaitRangeMs = [250, 850], + postWaitRangeMs = [180, 650], + scrollRangePx = [120, 420], + allowReverseScroll = true, + }: HumanBehaviorOptions = {}, +): Promise { + await waitRandomDuration(page, preWaitRangeMs); + await safeScroll(page, 'down', scrollRangePx); + + if (selector) { + try { + await page.evaluate(buildHumanPointerScript(selector)); + } catch { + // Keep the data collection / export flow running even if the selector is absent. + } + } + + if (allowReverseScroll && Math.random() < 0.35) { + await safeScroll(page, 'up', [40, Math.max(80, scrollRangePx[0])]); + } + + await waitRandomDuration(page, postWaitRangeMs); +} + +export async function clearLocalStorageForUrlHost(page: IPage, targetUrl: string): Promise { + const target = new URL(targetUrl); + await page.goto(target.origin, { waitUntil: 'load' }); + const result = await page.evaluate(buildClearLocalStorageScript(target.host)); + if (!result || typeof result !== 'object' || !(result as { ok?: boolean }).ok) { + throw new CommandExecutionError( + `Could not clear localStorage for ${target.host}`, + JSON.stringify(result ?? {}), + ); + } +} + +export const __test__ = { + RANDOM_DELAY_MULTIPLIER, + buildClearLocalStorageScript, + buildHumanPointerScript, + clearLocalStorageForUrlHost, + randomInRange, + waitRandomDuration, + simulateHumanBehavior, +}; diff --git a/extension/src/background.ts b/extension/src/background.ts index da132a9d4..001dd8e32 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -66,7 +66,11 @@ async function connect(): Promise { reconnectTimer = null; } // Send version so the daemon can report mismatches to the CLI - ws?.send(JSON.stringify({ type: 'hello', version: chrome.runtime.getManifest().version })); + ws?.send(JSON.stringify({ + type: 'hello', + version: chrome.runtime.getManifest().version, + extensionId: chrome.runtime.id, + })); }; ws.onmessage = async (event) => { diff --git a/package.json b/package.json index b47591e7d..1500ef7a5 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "./logger": "./dist/src/logger.js", "./launcher": "./dist/src/launcher.js", "./browser/cdp": "./dist/src/browser/cdp.js", + "./browser/daemon-client": "./dist/src/browser/daemon-client.js", "./browser/page": "./dist/src/browser/page.js", "./browser/utils": "./dist/src/browser/utils.js", "./download": "./dist/src/download/index.js", diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index 451256837..2cdf4e303 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -12,6 +12,7 @@ import { Page } from './page.js'; import { getDaemonHealth } from './daemon-client.js'; import { DEFAULT_DAEMON_PORT } from '../constants.js'; import { BrowserConnectError } from '../errors.js'; +import { MAYBE_BROWSER_SCRAPER_ID } from './extension-detect.js'; const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension @@ -78,6 +79,7 @@ export class BrowserBridge implements IBrowserFactory { 'Install the Browser Bridge:\n' + ' 1. Download: https://github.com/jackwener/opencli/releases\n' + ' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' + + ` 3. Confirm the extension ID is ${MAYBE_BROWSER_SCRAPER_ID}\n` + ' Then run: opencli doctor', 'extension-not-connected', ); @@ -116,6 +118,7 @@ export class BrowserBridge implements IBrowserFactory { 'Install the Browser Bridge:\n' + ' 1. Download: https://github.com/jackwener/opencli/releases\n' + ' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' + + ` 3. Confirm the extension ID is ${MAYBE_BROWSER_SCRAPER_ID}\n` + ' Then run: opencli doctor', 'extension-not-connected', ); diff --git a/src/browser/daemon-client.test.ts b/src/browser/daemon-client.test.ts index 7aadfe393..7a211a3cf 100644 --- a/src/browser/daemon-client.test.ts +++ b/src/browser/daemon-client.test.ts @@ -94,6 +94,7 @@ describe('daemon-client', () => { uptime: 10, extensionConnected: true, extensionVersion: '1.2.3', + extensionExpected: true, pending: 0, lastCliRequestTime: Date.now(), memoryMB: 32, @@ -106,4 +107,28 @@ describe('daemon-client', () => { await expect(getDaemonHealth()).resolves.toEqual({ state: 'ready', status }); }); + + it('treats a connected but mismatched extension id as no-extension', async () => { + const status = { + ok: true, + pid: 123, + uptime: 10, + extensionConnected: true, + extensionVersion: '1.2.3', + extensionId: 'wrongwrongwrongwrongwrongwrongwr', + expectedExtensionId: 'gjfgacldoekdalepfgdonkjfngmliogc', + extensionExpected: false, + lastRejectedExtensionId: 'wrongwrongwrongwrongwrongwrongwr', + pending: 0, + lastCliRequestTime: Date.now(), + memoryMB: 32, + port: 19825, + }; + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(status), + } as Response); + + await expect(getDaemonHealth()).resolves.toEqual({ state: 'no-extension', status }); + }); }); diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index b01b94b5a..5d5dffa62 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -21,7 +21,7 @@ function generateId(): string { export interface DaemonCommand { id: string; - action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind-current' | 'network-capture-start' | 'network-capture-read' | 'cdp'; + action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind-current' | 'network-capture-start' | 'network-capture-read' | 'cdp' | 'download-wait'; tabId?: number; code?: string; workspace?: string; @@ -31,6 +31,7 @@ export interface DaemonCommand { domain?: string; matchDomain?: string; matchPathPrefix?: string; + matchUrl?: string; format?: 'png' | 'jpeg'; quality?: number; fullPage?: boolean; @@ -45,6 +46,10 @@ export interface DaemonCommand { pattern?: string; cdpMethod?: string; cdpParams?: Record; + downloadTimeoutMs?: number; + downloadStartedAfterMs?: number; + downloadUrlPattern?: string; + downloadReferrerPattern?: string; } export interface DaemonResult { @@ -60,6 +65,10 @@ export interface DaemonStatus { uptime: number; extensionConnected: boolean; extensionVersion?: string; + extensionId?: string | null; + expectedExtensionId?: string; + extensionExpected?: boolean; + lastRejectedExtensionId?: string | null; pending: number; lastCliRequestTime: number; memoryMB: number; @@ -103,7 +112,7 @@ export type DaemonHealth = export async function getDaemonHealth(opts?: { timeout?: number }): Promise { const status = await fetchDaemonStatus(opts); if (!status) return { state: 'stopped', status: null }; - if (!status.extensionConnected) return { state: 'no-extension', status }; + if (!status.extensionConnected || status.extensionExpected === false) return { state: 'no-extension', status }; return { state: 'ready', status }; } @@ -171,6 +180,9 @@ export async function listSessions(): Promise { return Array.isArray(result) ? result : []; } -export async function bindCurrentTab(workspace: string, opts: { matchDomain?: string; matchPathPrefix?: string } = {}): Promise { +export async function bindCurrentTab( + workspace: string, + opts: { matchDomain?: string; matchPathPrefix?: string; matchUrl?: string } = {}, +): Promise { return sendCommand('bind-current', { workspace, ...opts }); } diff --git a/src/browser/extension-detect.test.ts b/src/browser/extension-detect.test.ts new file mode 100644 index 000000000..1e1cc7ea2 --- /dev/null +++ b/src/browser/extension-detect.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { + MAYBE_BROWSER_SCRAPER_ID, + isExpectedBrowserScraperId, + isExpectedExtensionOrigin, + parseExtensionIdFromOrigin, +} from './extension-detect.js'; + +describe('extension-detect', () => { + it('extracts the extension id from a chrome-extension origin', () => { + expect(parseExtensionIdFromOrigin(`chrome-extension://${MAYBE_BROWSER_SCRAPER_ID}`)).toBe( + MAYBE_BROWSER_SCRAPER_ID, + ); + }); + + it('returns null for non-extension origins', () => { + expect(parseExtensionIdFromOrigin('https://example.com')).toBeNull(); + expect(parseExtensionIdFromOrigin(undefined)).toBeNull(); + }); + + it('matches only the expected browser scraper id', () => { + expect(isExpectedBrowserScraperId(MAYBE_BROWSER_SCRAPER_ID)).toBe(true); + expect(isExpectedBrowserScraperId('abcdefghijklmnopabcdefghijklmnop')).toBe(false); + }); + + it('matches only the expected extension origin', () => { + expect(isExpectedExtensionOrigin(`chrome-extension://${MAYBE_BROWSER_SCRAPER_ID}`)).toBe(true); + expect(isExpectedExtensionOrigin('chrome-extension://abcdefghijklmnopabcdefghijklmnop')).toBe(false); + }); +}); diff --git a/src/browser/extension-detect.ts b/src/browser/extension-detect.ts new file mode 100644 index 000000000..da091c368 --- /dev/null +++ b/src/browser/extension-detect.ts @@ -0,0 +1,24 @@ +export const MAYBE_BROWSER_SCRAPER_ID = 'gjfgacldoekdalepfgdonkjfngmliogc'; + +export function parseExtensionIdFromOrigin(origin: string | null | undefined): string | null { + const value = typeof origin === 'string' ? origin.trim() : ''; + if (!value) return null; + + try { + const parsed = new URL(value); + if (parsed.protocol !== 'chrome-extension:') return null; + const extensionId = parsed.hostname.trim(); + return extensionId || null; + } catch { + return null; + } +} + +export function isExpectedBrowserScraperId(extensionId: string | null | undefined): boolean { + return typeof extensionId === 'string' + && extensionId.trim() === MAYBE_BROWSER_SCRAPER_ID; +} + +export function isExpectedExtensionOrigin(origin: string | null | undefined): boolean { + return isExpectedBrowserScraperId(parseExtensionIdFromOrigin(origin)); +} diff --git a/src/browser/page.ts b/src/browser/page.ts index e97f69553..fc62f8d51 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -10,7 +10,7 @@ * chrome-extension:// tab that can't be debugged. */ -import type { BrowserCookie, ScreenshotOptions } from '../types.js'; +import type { BrowserCookie, BrowserDownloadResult, DownloadWaitOptions, ScreenshotOptions } from '../types.js'; import { sendCommand } from './daemon-client.js'; import { wrapForEval } from './utils.js'; import { saveBase64ToFile } from '../utils.js'; @@ -201,6 +201,17 @@ export class Page extends BasePage { }); } + async waitForDownload(options: DownloadWaitOptions = {}): Promise { + const result = await sendCommand('download-wait', { + downloadTimeoutMs: options.timeoutMs, + downloadStartedAfterMs: options.startedAfterMs, + downloadUrlPattern: options.urlPattern, + downloadReferrerPattern: options.referrerPattern, + ...this._cmdOpts(), + }); + return result as BrowserDownloadResult; + } + /** CDP native click fallback — called when JS el.click() fails */ protected override async tryNativeClick(x: number, y: number): Promise { try { diff --git a/src/build-manifest.test.ts b/src/build-manifest.test.ts index 33bb9691e..f42602ffa 100644 --- a/src/build-manifest.test.ts +++ b/src/build-manifest.test.ts @@ -3,7 +3,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { cli, getRegistry, Strategy } from './registry.js'; -import { loadTsManifestEntries, shouldReplaceManifestEntry } from './build-manifest.js'; +import { loadTsManifestEntries, resolveManifestBuildPaths, shouldReplaceManifestEntry } from './build-manifest.js'; describe('manifest helper rules', () => { const tempDirs: string[] = []; @@ -60,6 +60,29 @@ describe('manifest helper rules', () => { )).toBe(false); }); + it('writes built manifests next to dist/clis when running from dist/', () => { + const packageRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-build-paths-')); + tempDirs.push(packageRoot); + fs.mkdirSync(path.join(packageRoot, 'dist', 'clis'), { recursive: true }); + fs.writeFileSync(path.join(packageRoot, 'package.json'), '{}'); + + expect(resolveManifestBuildPaths(path.join(packageRoot, 'src', 'build-manifest.ts'))).toMatchObject({ + packageRoot, + sourceClisDir: path.join(packageRoot, 'clis'), + scanClisDir: path.join(packageRoot, 'clis'), + output: path.join(packageRoot, 'cli-manifest.json'), + builtExecution: false, + }); + + expect(resolveManifestBuildPaths(path.join(packageRoot, 'dist', 'src', 'build-manifest.js'))).toMatchObject({ + packageRoot, + sourceClisDir: path.join(packageRoot, 'clis'), + scanClisDir: path.join(packageRoot, 'dist', 'clis'), + output: path.join(packageRoot, 'dist', 'cli-manifest.json'), + builtExecution: true, + }); + }); + it('skips TS files that do not register a cli', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-')); tempDirs.push(dir); diff --git a/src/build-manifest.ts b/src/build-manifest.ts index bf138970d..1708b50ca 100644 --- a/src/build-manifest.ts +++ b/src/build-manifest.ts @@ -5,8 +5,10 @@ * Scans all YAML/TS CLI definitions and pre-compiles them into a single * manifest.json for instant cold-start registration (no runtime YAML parsing). * - * Usage: npx tsx src/build-manifest.ts - * Output: cli-manifest.json at the package root + * Usage: + * - `npx tsx src/build-manifest.ts` during development + * - `node dist/src/build-manifest.js` during packaging + * Output: cli-manifest.json next to the scanned clis/ tree */ import * as fs from 'node:fs'; @@ -17,9 +19,35 @@ import { getErrorMessage } from './errors.js'; import { fullName, getRegistry, type CliCommand } from './registry.js'; import { findPackageRoot, getCliManifestPath } from './package-paths.js'; -const PACKAGE_ROOT = findPackageRoot(fileURLToPath(import.meta.url)); -const CLIS_DIR = path.join(PACKAGE_ROOT, 'clis'); -const OUTPUT = getCliManifestPath(CLIS_DIR); +export interface ManifestBuildPaths { + packageRoot: string; + sourceClisDir: string; + scanClisDir: string; + output: string; + builtExecution: boolean; +} + +export function resolveManifestBuildPaths(currentFile: string): ManifestBuildPaths { + const packageRoot = findPackageRoot(currentFile); + const sourceClisDir = path.join(packageRoot, 'clis'); + const distClisDir = path.join(packageRoot, 'dist', 'clis'); + const builtExecution = currentFile.replace(/\\/g, '/').includes('/dist/'); + const scanClisDir = builtExecution && fs.existsSync(distClisDir) ? distClisDir : sourceClisDir; + + return { + packageRoot, + sourceClisDir, + scanClisDir, + output: getCliManifestPath(scanClisDir), + builtExecution, + }; +} + +const BUILD_PATHS = resolveManifestBuildPaths(fileURLToPath(import.meta.url)); +const PACKAGE_ROOT = BUILD_PATHS.packageRoot; +const SOURCE_CLIS_DIR = BUILD_PATHS.sourceClisDir; +const CLIS_DIR = BUILD_PATHS.scanClisDir; +const OUTPUT = BUILD_PATHS.output; export interface ManifestEntry { site: string; @@ -46,9 +74,9 @@ export interface ManifestEntry { replacedBy?: string; /** 'yaml' or 'ts' — determines how executeCommand loads the handler */ type: 'yaml' | 'ts'; - /** Relative path from clis/ dir, e.g. 'bilibili/hot.yaml' or 'bilibili/search.js' */ + /** Relative path from the runtime clis/ dir, e.g. 'bilibili/hot.yaml' or 'bilibili/search.js' */ modulePath?: string; - /** Relative path to the original source file from clis/ dir (for YAML: 'site/cmd.yaml') */ + /** Relative path to the original source file from the source clis/ dir (for YAML: 'site/cmd.yaml') */ sourceFile?: string; /** Pre-navigation control — see CliCommand.navigateBefore */ navigateBefore?: boolean | string; @@ -73,9 +101,42 @@ function toManifestArgs(args: CliCommand['args']): ManifestEntry['args'] { })); } +function toPosixRelative(fromDir: string, filePath: string): string { + return path.relative(fromDir, filePath).split(path.sep).join(path.posix.sep); +} + +function isWithinDir(filePath: string, dir: string): boolean { + const relative = path.relative(dir, filePath); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + function toTsModulePath(filePath: string, site: string): string { - const baseName = path.basename(filePath, path.extname(filePath)); - return `${site}/${baseName}.js`; + if (!isWithinDir(filePath, CLIS_DIR)) { + const baseName = path.basename(filePath, path.extname(filePath)); + return `${site}/${baseName}.js`; + } + const relativePath = toPosixRelative(CLIS_DIR, filePath); + return relativePath.replace(/\.[^.]+$/, '.js'); +} + +function toSourceFilePath(filePath: string): string { + if (!isWithinDir(filePath, CLIS_DIR)) { + return path.basename(filePath); + } + const relativePath = path.relative(CLIS_DIR, filePath); + const sourceCandidate = path.join(SOURCE_CLIS_DIR, relativePath); + + if (fs.existsSync(sourceCandidate)) { + return toPosixRelative(SOURCE_CLIS_DIR, sourceCandidate); + } + if (filePath.endsWith('.js')) { + const tsCandidate = sourceCandidate.replace(/\.js$/, '.ts'); + if (fs.existsSync(tsCandidate)) { + return toPosixRelative(SOURCE_CLIS_DIR, tsCandidate); + } + } + + return relativePath.split(path.sep).join(path.posix.sep); } function isCliCommandValue(value: unknown, site: string): value is CliCommand { @@ -137,7 +198,7 @@ function scanYaml(filePath: string, site: string): ManifestEntry | null { deprecated: (cliDef as Record).deprecated as boolean | string | undefined, replacedBy: (cliDef as Record).replacedBy as string | undefined, type: 'yaml', - sourceFile: path.relative(CLIS_DIR, filePath), + sourceFile: toSourceFilePath(filePath), navigateBefore: cliDef.navigateBefore, }; } catch (err) { @@ -184,7 +245,7 @@ export async function loadTsManifestEntries( return true; }) .sort((a, b) => a.name.localeCompare(b.name)) - .map(cmd => toManifestEntry(cmd, modulePath, path.relative(CLIS_DIR, filePath))); + .map(cmd => toManifestEntry(cmd, modulePath, toSourceFilePath(filePath))); } catch (err) { // If parsing fails, log a warning (matching scanYaml behaviour) and skip the entry. process.stderr.write(`Warning: failed to scan ${filePath}: ${getErrorMessage(err)}\n`); diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index 81cc7bdce..9d9784a0a 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -31,6 +31,7 @@ import { CommandExecutionError, } from './errors.js'; import { getDaemonHealth } from './browser/daemon-client.js'; +import { MAYBE_BROWSER_SCRAPER_ID } from './browser/extension-detect.js'; import { isDiagnosticEnabled } from './diagnostic.js'; export function normalizeArgValue(argType: string | undefined, value: unknown, name: string): unknown { @@ -187,6 +188,7 @@ function renderBridgeStatus(running: boolean, extensionConnected: boolean): void console.error(chalk.yellow(' Install the Browser Bridge extension to continue:')); console.error(chalk.dim(' 1. Download from github.com/jackwener/opencli/releases')); console.error(chalk.dim(' 2. chrome://extensions → Enable Developer Mode → Load unpacked')); + console.error(chalk.dim(` 3. Confirm extension ID: ${MAYBE_BROWSER_SCRAPER_ID}`)); } else { console.error(chalk.yellow(' Connection failed despite extension being active.')); console.error(chalk.dim(' Try reloading the extension, or run: opencli doctor')); diff --git a/src/daemon.ts b/src/daemon.ts index d278f49d2..f0426eb04 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -24,6 +24,12 @@ import { WebSocketServer, WebSocket, type RawData } from 'ws'; import { DEFAULT_DAEMON_PORT, DEFAULT_DAEMON_IDLE_TIMEOUT } from './constants.js'; import { EXIT_CODES } from './errors.js'; import { IdleManager } from './idle-manager.js'; +import { + MAYBE_BROWSER_SCRAPER_ID, + isExpectedBrowserScraperId, + isExpectedExtensionOrigin, + parseExtensionIdFromOrigin, +} from './browser/extension-detect.js'; const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10); const IDLE_TIMEOUT = Number(process.env.OPENCLI_DAEMON_TIMEOUT ?? DEFAULT_DAEMON_IDLE_TIMEOUT); @@ -32,6 +38,8 @@ const IDLE_TIMEOUT = Number(process.env.OPENCLI_DAEMON_TIMEOUT ?? DEFAULT_DAEMON let extensionWs: WebSocket | null = null; let extensionVersion: string | null = null; +let extensionId: string | null = null; +let lastRejectedExtensionId: string | null = null; const pending = new Map void; reject: (error: Error) => void; @@ -132,6 +140,10 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise uptime, extensionConnected: extensionWs?.readyState === WebSocket.OPEN, extensionVersion, + extensionId, + expectedExtensionId: MAYBE_BROWSER_SCRAPER_ID, + extensionExpected: isExpectedBrowserScraperId(extensionId), + lastRejectedExtensionId, pending: pending.size, lastCliRequestTime: idleManager.lastCliRequestTime, memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10, @@ -208,19 +220,22 @@ const wss = new WebSocketServer({ server: httpServer, path: '/ext', verifyClient: ({ req }: { req: IncomingMessage }) => { - // Block browser-originated WebSocket connections. Browsers don't - // enforce CORS on WebSocket, so a malicious webpage could connect to - // ws://localhost:19825/ext and impersonate the Extension. Real Chrome - // Extensions send origin chrome-extension://. const origin = req.headers['origin'] as string | undefined; - return !origin || origin.startsWith('chrome-extension://'); + const incomingId = parseExtensionIdFromOrigin(origin); + if (!isExpectedExtensionOrigin(origin)) { + lastRejectedExtensionId = incomingId; + return false; + } + lastRejectedExtensionId = null; + return true; }, }); -wss.on('connection', (ws: WebSocket) => { +wss.on('connection', (ws: WebSocket, req: IncomingMessage) => { console.error('[daemon] Extension connected'); extensionWs = ws; extensionVersion = null; // cleared until hello message arrives + extensionId = parseExtensionIdFromOrigin(req.headers['origin'] as string | undefined); idleManager.setExtensionConnected(true); // ── Heartbeat: ping every 15s, close if 2 pongs missed ── @@ -251,6 +266,14 @@ wss.on('connection', (ws: WebSocket) => { // Handle hello message from extension (version handshake) if (msg.type === 'hello') { extensionVersion = typeof msg.version === 'string' ? msg.version : null; + const incomingId = typeof msg.extensionId === 'string' ? msg.extensionId : null; + if (!isExpectedBrowserScraperId(incomingId)) { + lastRejectedExtensionId = incomingId; + ws.close(); + return; + } + extensionId = incomingId; + lastRejectedExtensionId = null; return; } @@ -280,6 +303,7 @@ wss.on('connection', (ws: WebSocket) => { if (extensionWs === ws) { extensionWs = null; extensionVersion = null; + extensionId = null; idleManager.setExtensionConnected(false); // Reject all pending requests since the extension is gone for (const [id, p] of pending) { @@ -295,6 +319,7 @@ wss.on('connection', (ws: WebSocket) => { if (extensionWs === ws) { extensionWs = null; extensionVersion = null; + extensionId = null; idleManager.setExtensionConnected(false); // Reject pending requests in case 'close' does not follow this 'error' for (const [, p] of pending) { diff --git a/src/discovery.ts b/src/discovery.ts index 4c5064972..a30a7e10e 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -164,7 +164,9 @@ async function loadFromManifest(manifestPath: string, clisDir: string): Promise< columns: entry.columns, pipeline: entry.pipeline, timeoutSeconds: entry.timeout, - source: entry.sourceFile ? path.resolve(clisDir, entry.sourceFile) : `manifest:${entry.site}/${entry.name}`, + source: entry.sourceFile + ? resolveManifestSourcePath(clisDir, entry.sourceFile) + : `manifest:${entry.site}/${entry.name}`, deprecated: entry.deprecated, replacedBy: entry.replacedBy, navigateBefore: entry.navigateBefore, @@ -186,7 +188,7 @@ async function loadFromManifest(manifestPath: string, clisDir: string): Promise< args: entry.args ?? [], columns: entry.columns, timeoutSeconds: entry.timeout, - source: entry.sourceFile ? path.resolve(clisDir, entry.sourceFile) : modulePath, + source: entry.sourceFile ? resolveManifestSourcePath(clisDir, entry.sourceFile) : modulePath, deprecated: entry.deprecated, replacedBy: entry.replacedBy, navigateBefore: entry.navigateBefore, @@ -203,6 +205,20 @@ async function loadFromManifest(manifestPath: string, clisDir: string): Promise< } } +function resolveManifestSourcePath(clisDir: string, sourceFile: string): string { + const direct = path.resolve(clisDir, sourceFile); + if (fs.existsSync(direct)) return direct; + + const normalizedClisDir = clisDir.replace(/\\/g, '/'); + if (normalizedClisDir.endsWith('/dist/clis')) { + const packageRoot = path.resolve(clisDir, '..', '..'); + const sourceCandidate = path.resolve(packageRoot, 'clis', sourceFile); + if (fs.existsSync(sourceCandidate)) return sourceCandidate; + } + + return direct; +} + /** * Fallback: traditional filesystem scan (used during development with tsx). */ diff --git a/src/doctor.ts b/src/doctor.ts index bff86d368..acb9a0f0f 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -8,6 +8,7 @@ import chalk from 'chalk'; import { DEFAULT_DAEMON_PORT } from './constants.js'; import { BrowserBridge } from './browser/index.js'; import { getDaemonHealth, listSessions } from './browser/daemon-client.js'; +import { MAYBE_BROWSER_SCRAPER_ID } from './browser/extension-detect.js'; import { getErrorMessage } from './errors.js'; import { getRuntimeLabel } from './runtime-detect.js'; @@ -101,12 +102,15 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise { + const tempBuildRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-manifest-source-')); + const distClisDir = path.join(tempBuildRoot, 'dist', 'clis'); + const distSiteDir = path.join(distClisDir, 'manifest-site'); + const sourceSiteDir = path.join(tempBuildRoot, 'clis', 'manifest-site'); + const manifestPath = path.join(tempBuildRoot, 'dist', 'cli-manifest.json'); + const sourcePath = path.join(sourceSiteDir, 'hello.ts'); + const modulePath = path.join(distSiteDir, 'hello.js'); + + try { + await fs.promises.mkdir(distSiteDir, { recursive: true }); + await fs.promises.mkdir(sourceSiteDir, { recursive: true }); + await fs.promises.writeFile(sourcePath, 'export const source = true;\n'); + await fs.promises.writeFile(modulePath, 'export const compiled = true;\n'); + await fs.promises.writeFile(manifestPath, JSON.stringify([ + { + site: 'manifest-site', + name: 'hello', + description: 'hello command', + strategy: 'public', + browser: false, + args: [], + type: 'ts', + modulePath: 'manifest-site/hello.js', + sourceFile: 'manifest-site/hello.ts', + }, + ])); + + await discoverClis(distClisDir); + + const cmd = getRegistry().get('manifest-site/hello'); + expect(cmd).toBeDefined(); + expect(cmd!.source).toBe(sourcePath); + expect((cmd as any)._modulePath).toBe(modulePath); + } finally { + getRegistry().delete('manifest-site/hello'); + await fs.promises.rm(tempBuildRoot, { recursive: true, force: true }); + } + }); + it('loads user CLI modules via package exports symlink', async () => { const tempOpencliRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-user-clis-')); const userClisDir = path.join(tempOpencliRoot, 'clis'); diff --git a/src/extension-manifest-regression.test.ts b/src/extension-manifest-regression.test.ts index 5a0bd68c4..cf74277ee 100644 --- a/src/extension-manifest-regression.test.ts +++ b/src/extension-manifest-regression.test.ts @@ -12,6 +12,7 @@ describe('extension manifest regression', () => { }; expect(manifest.permissions).toContain('cookies'); + expect(manifest.permissions).toContain('downloads'); expect(manifest.host_permissions).toContain(''); }); }); diff --git a/src/types.ts b/src/types.ts index 952a7e21a..a2ba6b0b5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -38,6 +38,28 @@ export interface ScreenshotOptions { path?: string; } +export interface DownloadWaitOptions { + timeoutMs?: number; + startedAfterMs?: number; + urlPattern?: string; + referrerPattern?: string; +} + +export interface BrowserDownloadResult { + downloadId?: number; + filename: string; + url?: string; + finalUrl?: string; + referrer?: string; + mime?: string; + state?: string; + startTime?: string; + endTime?: string; + fileSize?: number; + totalBytes?: number; + exists?: boolean; +} + export interface BrowserSessionInfo { workspace?: string; connected?: boolean; @@ -86,6 +108,8 @@ export interface IPage { getActiveTabId?(): number | undefined; /** Send a raw CDP command via chrome.debugger passthrough. */ cdp?(method: string, params?: Record): Promise; + /** Wait for a browser download triggered by the active page and return the local file path. */ + waitForDownload?(options?: DownloadWaitOptions): Promise; /** Click at native coordinates via CDP Input.dispatchMouseEvent. */ nativeClick?(x: number, y: number): Promise; /** Type text via CDP Input.insertText. */