|
| 1 | +import { type Data, type DataItem, type Route, ViewType } from '@/types'; |
| 2 | + |
| 3 | +import { art } from '@/utils/render'; |
| 4 | +import ofetch from '@/utils/ofetch'; |
| 5 | +import { parseDate } from '@/utils/parse-date'; |
| 6 | + |
| 7 | +import { type CheerioAPI, load } from 'cheerio'; |
| 8 | +import { type Context } from 'hono'; |
| 9 | +import path from 'node:path'; |
| 10 | + |
| 11 | +const orderbys = (desc: string) => { |
| 12 | + const base = { |
| 13 | + 0: 'search.score() desc, Metadata/OfficialRepositoryNumber desc, NameSortable asc', |
| 14 | + 1: 'NameSortable asc, Metadata/OfficialRepositoryNumber desc, Metadata/RepositoryStars desc, Metadata/Committed desc', |
| 15 | + 2: 'Metadata/Committed desc, Metadata/OfficialRepositoryNumber desc, Metadata/RepositoryStars desc', |
| 16 | + }; |
| 17 | + |
| 18 | + if (desc === '1') { |
| 19 | + return base; |
| 20 | + } |
| 21 | + |
| 22 | + const inverted = {}; |
| 23 | + for (const key in base) { |
| 24 | + const orderStr = base[key]; |
| 25 | + inverted[key] = orderStr.replaceAll(/\b(desc|asc)\b/gi, (match) => (match.toLowerCase() === 'desc' ? 'asc' : 'desc')); |
| 26 | + } |
| 27 | + return inverted; |
| 28 | +}; |
| 29 | + |
| 30 | +const filters = { |
| 31 | + o: 'Metadata/OfficialRepositoryNumber eq 1', // offical buckets only |
| 32 | + dm: 'Metadata/DuplicateOf eq null', // distinct manifests only |
| 33 | +}; |
| 34 | + |
| 35 | +export const handler = async (ctx: Context): Promise<Data> => { |
| 36 | + const { query = 's=2&d=1&n=true&dm=true&o=true' } = ctx.req.param(); |
| 37 | + const limit: number = Number.parseInt(ctx.req.query('limit') ?? '50', 10); |
| 38 | + |
| 39 | + const baseUrl: string = 'https://scoop.sh'; |
| 40 | + const apiBaseUrl: string = 'https://scoopsearch.search.windows.net'; |
| 41 | + const targetUrl: string = new URL(`/#/apps?${query}`, baseUrl).href; |
| 42 | + const apiUrl: string = new URL('indexes/apps/docs/search', apiBaseUrl).href; |
| 43 | + |
| 44 | + const targetResponse = await ofetch(targetUrl); |
| 45 | + const $: CheerioAPI = load(targetResponse); |
| 46 | + const language = $('html').attr('lang') ?? 'en'; |
| 47 | + |
| 48 | + const scriptRegExp: RegExp = /<script type="module" crossorigin src="(.*?)"><\/script>/; |
| 49 | + const scriptUrl: string = scriptRegExp.test(targetResponse) ? new URL(targetResponse.match(scriptRegExp)?.[1], baseUrl).href : ''; |
| 50 | + |
| 51 | + if (!scriptUrl) { |
| 52 | + throw new Error('JavaScript file not found.'); |
| 53 | + } |
| 54 | + |
| 55 | + const scriptResponse = await ofetch(scriptUrl, { |
| 56 | + parseResponse: (txt) => txt, |
| 57 | + }); |
| 58 | + |
| 59 | + const key: string = scriptResponse.match(/VITE_APP_AZURESEARCH_KEY:"(.*?)"/)?.[1]; |
| 60 | + |
| 61 | + if (!key) { |
| 62 | + throw new Error('Key not found.'); |
| 63 | + } |
| 64 | + |
| 65 | + const isOffcial: boolean = !query.includes('o=false'); |
| 66 | + const isDistinct: boolean = !query.includes('dm=false'); |
| 67 | + const sort: string = query.match(/s=(\d+)/)?.[1] ?? '2'; |
| 68 | + const desc: string = query.match(/d=(\d+)/)?.[1] ?? '1'; |
| 69 | + |
| 70 | + const response = await ofetch(apiUrl, { |
| 71 | + method: 'post', |
| 72 | + query: { |
| 73 | + 'api-version': '2020-06-30', |
| 74 | + }, |
| 75 | + headers: { |
| 76 | + 'api-key': key, |
| 77 | + origin: baseUrl, |
| 78 | + referer: baseUrl, |
| 79 | + }, |
| 80 | + body: { |
| 81 | + count: true, |
| 82 | + search: '', |
| 83 | + searchMode: 'all', |
| 84 | + filter: [isOffcial ? filters.o : undefined, isDistinct ? filters.dm : undefined].filter(Boolean).join(' and '), |
| 85 | + orderby: orderbys(desc)[sort], |
| 86 | + skip: 0, |
| 87 | + top: limit, |
| 88 | + select: 'Id,Name,NamePartial,NameSuffix,Description,Notes,Homepage,License,Version,Metadata/Repository,Metadata/FilePath,Metadata/OfficialRepository,Metadata/RepositoryStars,Metadata/Committed,Metadata/Sha', |
| 89 | + highlight: 'Name,NamePartial,NameSuffix,Description,Version,License,Metadata/Repository', |
| 90 | + highlightPreTag: '<mark>', |
| 91 | + highlightPostTag: '</mark>', |
| 92 | + }, |
| 93 | + }); |
| 94 | + |
| 95 | + let items: DataItem[] = []; |
| 96 | + |
| 97 | + items = response.value.slice(0, limit).map((item): DataItem => { |
| 98 | + const repositorySplits: string[] = item.Metadata.Repository.split(/\//); |
| 99 | + const repositoryName: string = repositorySplits.slice(-2).join('/'); |
| 100 | + const title: string = `${item.Name} ${item.Version} in ${repositoryName}`; |
| 101 | + const description: string | undefined = art(path.join(__dirname, 'templates/description.art'), { |
| 102 | + item, |
| 103 | + }); |
| 104 | + const pubDate: number | string = item.Metadata.Committed; |
| 105 | + const linkUrl: string | undefined = item.Homepage; |
| 106 | + const authors: DataItem['author'] = [ |
| 107 | + { |
| 108 | + name: repositoryName, |
| 109 | + url: item.Metadata.Repository, |
| 110 | + avatar: undefined, |
| 111 | + }, |
| 112 | + ]; |
| 113 | + const guid: string = `scoop-${item.Name}-${item.Version}-${item.Metadata.Sha}`; |
| 114 | + const updated: number | string = pubDate; |
| 115 | + |
| 116 | + const processedItem: DataItem = { |
| 117 | + title, |
| 118 | + description, |
| 119 | + pubDate: pubDate ? parseDate(pubDate) : undefined, |
| 120 | + link: linkUrl, |
| 121 | + author: authors, |
| 122 | + guid, |
| 123 | + id: guid, |
| 124 | + content: { |
| 125 | + html: description, |
| 126 | + text: description, |
| 127 | + }, |
| 128 | + updated: updated ? parseDate(updated) : undefined, |
| 129 | + language, |
| 130 | + }; |
| 131 | + |
| 132 | + return processedItem; |
| 133 | + }); |
| 134 | + |
| 135 | + const author: string = 'Scoop'; |
| 136 | + |
| 137 | + return { |
| 138 | + title: `${author} - Apps`, |
| 139 | + description: undefined, |
| 140 | + link: targetUrl, |
| 141 | + item: items, |
| 142 | + allowEmpty: true, |
| 143 | + author, |
| 144 | + language, |
| 145 | + id: targetUrl, |
| 146 | + }; |
| 147 | +}; |
| 148 | + |
| 149 | +export const route: Route = { |
| 150 | + path: '/apps/:query?', |
| 151 | + name: 'Apps', |
| 152 | + url: 'scoop.sh', |
| 153 | + maintainers: ['nczitzk'], |
| 154 | + handler, |
| 155 | + example: '/scoop/apps', |
| 156 | + parameters: { |
| 157 | + query: { |
| 158 | + description: 'Query, `s=2&d=1&n=true&dm=true&o=true` by default', |
| 159 | + }, |
| 160 | + }, |
| 161 | + description: `:::tip |
| 162 | +To subscribe to [Apps](https://scoop.sh/#/apps?s=2&d=1&n=true&dm=true&o=true), where the source URL is \`https://scoop.sh/#/apps?s=2&d=1&n=true&dm=true&o=true\`, extract the certain parts from this URL to be used as parameters, resulting in the route as [\`/scoop/apps/s=2&d=1&n=true&dm=true&o=true\`](https://rsshub.app/scoop/apps/s=2&d=1&n=true&dm=true&o=true). |
| 163 | +
|
| 164 | +::: |
| 165 | +`, |
| 166 | + categories: ['program-update'], |
| 167 | + features: { |
| 168 | + requireConfig: false, |
| 169 | + requirePuppeteer: false, |
| 170 | + antiCrawler: false, |
| 171 | + supportRadar: true, |
| 172 | + supportBT: false, |
| 173 | + supportPodcast: false, |
| 174 | + supportScihub: false, |
| 175 | + }, |
| 176 | + radar: [ |
| 177 | + { |
| 178 | + source: ['scoop.sh/#/apps', 'scoop.sh'], |
| 179 | + target: (_, url) => { |
| 180 | + const urlObj: URL = new URL(url); |
| 181 | + const query: string | undefined = urlObj.searchParams.toString() ?? undefined; |
| 182 | + |
| 183 | + return `/scoop/apps${query ? `/${query}` : ''}`; |
| 184 | + }, |
| 185 | + }, |
| 186 | + ], |
| 187 | + view: ViewType.Notifications, |
| 188 | +}; |
0 commit comments