diff --git a/lib/routes/sotwe/namespace.ts b/lib/routes/sotwe/namespace.ts
new file mode 100644
index 0000000000..ecea84aa69
--- /dev/null
+++ b/lib/routes/sotwe/namespace.ts
@@ -0,0 +1 @@
+export { namespace } from '@/routes/twitter/namespace';
diff --git a/lib/routes/sotwe/user.ts b/lib/routes/sotwe/user.ts
new file mode 100644
index 0000000000..18bbe5de88
--- /dev/null
+++ b/lib/routes/sotwe/user.ts
@@ -0,0 +1,98 @@
+import { Route, ViewType } from '@/types';
+
+import { parseDate } from '@/utils/parse-date';
+import sanitizeHtml from 'sanitize-html';
+import puppeteer from '@/utils/puppeteer';
+import logger from '@/utils/logger';
+import cache from '@/utils/cache';
+import { config } from '@/config';
+
+export const route: Route = {
+ path: '/user/:id',
+ categories: ['social-media'],
+ example: '/sotwe/user/_RSSHub',
+ parameters: {
+ id: 'Twitter username',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: true,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.sotwe.com/:id'],
+ target: '/user/:id',
+ },
+ ],
+ name: 'User timeline - Sotwe',
+ maintainers: ['TonyRL'],
+ handler,
+ view: ViewType.Pictures,
+};
+
+const renderMedia = (mediaEntities) =>
+ mediaEntities
+ .map((e) => {
+ switch (e.type) {
+ case 'photo':
+ return `
`;
+ case 'video': {
+ const video = e.videoInfo.variants.filter((v) => v.type === 'video/mp4').sort((a, b) => b.bitrate - a.bitrate)[0];
+ return ``;
+ }
+ default:
+ return '';
+ }
+ })
+ .join('
');
+
+const renderDescription = (item) =>
+ `${renderMedia(item.mediaEntities)}
${item.text.replaceAll('\n', '
')}${item.quotedStatus ? `
${renderDescription(item.quotedStatus)}` : ''}${item.retweetedStatus ? `
${renderDescription(item.retweetedStatus)}` : ''}`;
+
+async function handler(ctx) {
+ const baseUrl = 'https://www.sotwe.com';
+ const { id } = ctx.req.param();
+
+ const data = await cache.tryGet(
+ `sotwe:user:${id}`,
+ async () => {
+ const browser = await puppeteer();
+ const page = await browser.newPage();
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ request.resourceType() === 'document' ? request.continue() : request.abort();
+ });
+ const apiUrl = `${baseUrl}/api/v3/user/${id}/`;
+ logger.http(`Requesting ${apiUrl}`);
+ await page.goto(apiUrl, {
+ waitUntil: 'domcontentloaded',
+ });
+ const response = await page.evaluate(() => document.documentElement.textContent);
+ await page.close();
+ await browser.close();
+
+ return JSON.parse(response || '{}');
+ },
+ config.cache.routeExpire,
+ false
+ );
+
+ const items = data.data.map((item) => ({
+ title: sanitizeHtml(item.text.split('\n')[0], { allowedTags: [], allowedAttributes: {} }),
+ description: renderDescription(item),
+ link: `https://x.com/${id}/status/${item.id}`,
+ pubDate: parseDate(item.createdAt, 'x'),
+ }));
+
+ return {
+ title: `${data.info.name} @${data.info.screenName} - Twitter Profile | Sotwe`,
+ description: data.info.description,
+ link: `${baseUrl}/${id}`,
+ image: data.info.profileImageThumbnail,
+ item: items,
+ };
+}