search.js
1 import { CommandExecutionError } from '@jackwener/opencli/errors'; 2 import { cli, Strategy } from '@jackwener/opencli/registry'; 3 function headers() { 4 return { 5 'User-Agent': 'Mozilla/5.0', 6 Accept: 'application/json', 7 }; 8 } 9 function trim(value) { 10 return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''; 11 } 12 function publicationBaseUrl(publication) { 13 if (publication?.custom_domain) 14 return `https://${publication.custom_domain}`; 15 if (publication?.subdomain) 16 return `https://${publication.subdomain}.substack.com`; 17 return ''; 18 } 19 async function searchPosts(keyword, limit) { 20 const url = new URL('https://substack.com/api/v1/post/search'); 21 url.searchParams.set('query', keyword); 22 url.searchParams.set('page', '0'); 23 url.searchParams.set('includePlatformResults', 'true'); 24 const resp = await fetch(url, { headers: headers() }); 25 if (!resp.ok) 26 throw new CommandExecutionError(`Substack post search failed: HTTP ${resp.status}`); 27 const data = await resp.json(); 28 const results = Array.isArray(data?.results) ? data.results : []; 29 return results.slice(0, limit).map((item, index) => ({ 30 rank: index + 1, 31 title: trim(item?.title), 32 author: trim(item?.publishedBylines?.[0]?.name), 33 date: trim(item?.post_date).split('T')[0] || trim(item?.post_date), 34 description: trim(item?.description || item?.subtitle || item?.truncated_body_text).slice(0, 150), 35 url: trim(item?.canonical_url), 36 })); 37 } 38 async function searchPublications(keyword, limit) { 39 const url = new URL('https://substack.com/api/v1/profile/search'); 40 url.searchParams.set('query', keyword); 41 url.searchParams.set('page', '0'); 42 const resp = await fetch(url, { headers: headers() }); 43 if (!resp.ok) 44 throw new CommandExecutionError(`Substack publication search failed: HTTP ${resp.status}`); 45 const data = await resp.json(); 46 const results = Array.isArray(data?.results) ? data.results : []; 47 return results.slice(0, limit).map((item, index) => { 48 const publication = item?.primaryPublication || item?.publicationUsers?.[0]?.publication || {}; 49 return { 50 rank: index + 1, 51 title: trim(publication?.name || item?.name), 52 author: trim(item?.name), 53 date: '', 54 description: trim(publication?.hero_text || item?.bio).slice(0, 150), 55 url: publicationBaseUrl(publication), 56 }; 57 }); 58 } 59 cli({ 60 site: 'substack', 61 name: 'search', 62 description: '搜索 Substack 文章和 Newsletter', 63 domain: 'substack.com', 64 strategy: Strategy.PUBLIC, 65 browser: false, 66 args: [ 67 { name: 'keyword', required: true, positional: true, help: '搜索关键词' }, 68 { name: 'type', default: 'posts', choices: ['posts', 'publications'], help: '搜索类型(posts=文章, publications=Newsletter)' }, 69 { name: 'limit', type: 'int', default: 20, help: '返回结果数量' }, 70 ], 71 columns: ['rank', 'title', 'author', 'date', 'description', 'url'], 72 func: async (_page, args) => { 73 const limit = Math.max(1, Math.min(Number(args.limit) || 20, 50)); 74 return args.type === 'publications' 75 ? searchPublications(args.keyword, limit) 76 : searchPosts(args.keyword, limit); 77 }, 78 });