Skip to content

Commit 4bfd5b4

Browse files
feat(route): add Scoop Apps (#19459)
* chore(deps): bump @scalar/hono-api-reference from 0.9.4 to 0.9.5 (#2230) Bumps [@scalar/hono-api-reference](https://github.com/scalar/scalar/tree/HEAD/integrations/hono) from 0.9.4 to 0.9.5. - [Changelog](https://github.com/scalar/scalar/blob/main/integrations/hono/CHANGELOG.md) - [Commits](https://github.com/scalar/scalar/commits/HEAD/integrations/hono) --- updated-dependencies: - dependency-name: "@scalar/hono-api-reference" dependency-version: 0.9.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(route): add Scoop Apps --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1 parent 2f64e6c commit 4bfd5b4

File tree

3 files changed

+253
-0
lines changed

3 files changed

+253
-0
lines changed

lib/routes/scoop/apps.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
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+
};

lib/routes/scoop/namespace.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { Namespace } from '@/types';
2+
3+
export const namespace: Namespace = {
4+
name: 'Scoop',
5+
url: 'scoop.sh',
6+
categories: ['program-update'],
7+
description: '',
8+
lang: 'en',
9+
};
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
{{ if item }}
2+
<table>
3+
<tbody>
4+
{{ if item.Name }}
5+
<tr>
6+
<th>Name</th>
7+
<td>{{ item.Name }}</td>
8+
</tr>
9+
{{ /if }}
10+
{{ if item.Repository }}
11+
<tr>
12+
<th>Repository</th>
13+
<td>
14+
<a href="{{ item.Metadata.Repository }}">{{ item.Metadata.Repository.split(/\//).slice(-2).join('/') }}</a>
15+
</td>
16+
</tr>
17+
{{ /if }}
18+
{{ if item.Committed }}
19+
<tr>
20+
<th>Committed</th>
21+
<td><a href="{{ item.Metadata.Repository }}/commit/{{ item.Metadata.Sha }}">{{ item.Metadata.Committed }}</a></td>
22+
</tr>
23+
{{ /if }}
24+
{{ if item.Version }}
25+
<tr>
26+
<th>Version</th>
27+
<td><a href="{{ item.Metadata.Repository }}/blob/{{ item.Metadata.FilePath }}">v{{ item.Version }}</a></td>
28+
</tr>
29+
{{ /if }}
30+
{{ if item.Description }}
31+
<tr>
32+
<th>Description</th>
33+
<td>{{ item.Description }}</td>
34+
</tr>
35+
{{ /if }}
36+
{{ if item.Homepage }}
37+
</tr>
38+
<th>Homepage</th>
39+
<td><a href="{{ item.Homepage }}">{{ item.Homepage }}</a></td>
40+
</tr>
41+
{{ /if }}
42+
{{ if item.License }}
43+
<tr>
44+
<th>License</th>
45+
<td>{{ item.License }}</td>
46+
</tr>
47+
{{ /if }}
48+
{{ if item.Note }}
49+
<tr>
50+
<th>Note</th>
51+
<td>{{ item.Note }}</td>
52+
</tr>
53+
{{ /if }}
54+
</tbody>
55+
</table>
56+
{{ /if }}

0 commit comments

Comments
 (0)