/ clis / substack / search.js
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  });