feat(route): add support for f95zone.to (#21042)

* feat(route): add f95zone routes

* fix(route): fix f95zone threads route for folo parse

* fix(route): parse f95zone thread guid from [YYYY-MM-DD] in title

* fix(route): use update date in thread title as pubDate

* fix(route/f95zone): revert image URL processing

* fix(route/f95zone): reenable image URL processing for telegram-bot

* feat(route/f95zone): support post and thread route

* feat(route/f95zone): optimize title

* feat(route/f95zone): optimize image display

* feat(route/f95zone): optimize image display

* feat(route/f95zone): reoptimize img-elements processing

* feat(route/f95zone): reset to the original route pattern

* feat(route/f95zone): optimize routes

* feat(route/f95zone): optimize content processing

* feat(route/f95zone): reduce code

* fix(route/f95zone): use parseDate for proper date handling

* fix(route/f95zone): remove broken radar

* fix(route/f95zone): remove invalid radar config and improve docs

- Remove radar config from post route (postId is in URL hash, cannot be extracted)
- Add empty radar array to prevent Folo parsing error
- Add detailed URL format documentation for both routes
- Explain how to convert browser URL to subscription URL

* fix(route/f95zone): fix invalid example URL

* fix(route/f95zone): use internally generated UA

* fix(route/f95zone): only fetch the first and the last page

* fix(route/f95zone): remove unnecessary sorting logic
This commit is contained in:
wsmbsbbz 2026-02-04 02:43:36 +08:00 committed by GitHub
parent 5ae169a201
commit dc68ecb875
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 274 additions and 0 deletions

View File

@ -102,6 +102,7 @@ type ConfigEnvKeys =
| 'EH_STAR'
| 'EH_IMG_PROXY'
| `EMAIL_CONFIG_${string}`
| 'F95ZONE_COOKIE'
| 'FANBOX_SESSION_ID'
| 'FANFOU_CONSUMER_KEY'
| 'FANFOU_CONSUMER_SECRET'
@ -392,6 +393,9 @@ export type Config = {
email: {
config: Record<string, string | undefined>;
};
f95zone: {
cookie?: string;
};
fanbox: {
session?: string;
};
@ -871,6 +875,9 @@ const calculateValue = () => {
email: {
config: email_config,
},
f95zone: {
cookie: envs.F95ZONE_COOKIE,
},
fanbox: {
session: envs.FANBOX_SESSION_ID,
},

View File

@ -0,0 +1,7 @@
import type { Namespace } from '@/types';
export const namespace: Namespace = {
name: 'F95zone',
url: 'f95zone.to',
description: 'F95zone is a community for adult games and animations.',
};

View File

@ -0,0 +1,85 @@
import { load } from 'cheerio';
import { config } from '@/config';
import type { Route } from '@/types';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import { processContent } from './utils';
export const route: Route = {
path: '/post/:thread/:postId',
name: 'Post',
maintainers: ['wsmbsbbz'],
example: '/f95zone/post/vicineko-collection-2025-06-14-vicineko.84596/post-5909830',
categories: ['game'],
description: `Track content changes of a specific post. Uses the date \`[yyyy-mm-dd]\` in title for update detection.
URL format: \`https://f95zone.to/threads/{thread}/#post-{id}\` → replace \`#\` with \`/\` to get \`/f95zone/post/{thread}/post-{id}\`
Example: \`https://f95zone.to/threads/vicineko-collection-2025-06-14-vicineko.84596/#post-5909830\`\`/f95zone/post/vicineko-collection-2025-06-14-vicineko.84596/post-5909830\`
Note: This route does not support Radar auto-detection because the post ID is in the URL hash (after \`#\`), which cannot be extracted by Radar. You need to manually construct the subscription URL.`,
parameters: {
thread: 'Thread slug with ID',
postId: 'Post ID with `post-` prefix, replace `#` with `/` from browser URL',
},
features: {
requireConfig: [
{
name: 'F95ZONE_COOKIE',
optional: true,
description: 'F95zone cookie for accessing restricted content.',
},
],
requirePuppeteer: false,
antiCrawler: false,
supportBT: false,
supportPodcast: false,
supportScihub: false,
nsfw: true,
},
radar: [],
handler: async (ctx) => {
const { thread, postId } = ctx.req.param();
const baseUrl = 'https://f95zone.to';
const link = `${baseUrl}/threads/${thread}/#${postId}`;
const response = await ofetch(link, {
headers: {
referer: baseUrl,
...(config.f95zone.cookie ? { cookie: config.f95zone.cookie } : {}),
},
});
const $ = load(response);
const title = $('h1.p-title-value').text().trim();
const post = $(`article[data-content="${postId}"]`);
const content = post.find('.bbWrapper').html() || '';
const author = post.attr('data-author') || '';
const postDate = post.find('time.u-dt').first().attr('datetime');
const tags = $('a.tagItem')
.toArray()
.map((el) => $(el).text().trim());
// Extract [yyyy-mm-dd] from title for update tracking
const dateMatch = title.match(/\[(\d{4}-\d{2}-\d{2})\]/);
const updateDate = dateMatch?.[1];
return {
title: `[F95zone] ${title}`,
link,
item: [
{
title: `[Updated] ${title}`,
link,
guid: updateDate ? `${link}_${updateDate}` : link,
description: processContent(content),
pubDate: updateDate ? parseDate(updateDate) : postDate ? parseDate(postDate) : undefined,
author,
category: tags,
},
],
};
},
};

View File

@ -0,0 +1,107 @@
import { load } from 'cheerio';
import { config } from '@/config';
import type { DataItem, Route } from '@/types';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import { processContent } from './utils';
export const route: Route = {
path: '/thread/:thread',
name: 'Thread',
maintainers: ['wsmbsbbz'],
example: '/f95zone/thread/ubermation-collection-2026-01-19-uebermation-uebermation.231247',
categories: ['game'],
description: `Track replies in a thread. Fetches the first page and the last page.
URL format: \`https://f95zone.to/threads/{thread}/\` → use \`{thread}\` as the parameter.
Example: \`https://f95zone.to/threads/ubermation-collection-2026-01-19-uebermation-uebermation.231247/\`\`/f95zone/thread/ubermation-collection-2026-01-19-uebermation-uebermation.231247\`
Note: If you want to track a specific post's content changes (e.g., first post with download links), use the \`/f95zone/post\` route instead.`,
parameters: {
thread: 'Thread slug with ID, copy from browser URL after `/threads/`',
},
features: {
requireConfig: [
{
name: 'F95ZONE_COOKIE',
optional: true,
description: 'F95zone cookie for accessing restricted content.',
},
],
requirePuppeteer: false,
antiCrawler: false,
supportBT: false,
supportPodcast: false,
supportScihub: false,
nsfw: true,
},
radar: [
{
source: ['f95zone.to/threads/:thread/*'],
target: '/thread/:thread',
},
],
handler: async (ctx) => {
const { thread } = ctx.req.param();
const baseUrl = 'https://f95zone.to';
const threadLink = `${baseUrl}/threads/${thread}/`;
const headers = {
referer: baseUrl,
...(config.f95zone.cookie ? { cookie: config.f95zone.cookie } : {}),
};
const firstPageResponse = await ofetch(threadLink, { headers });
const $firstPage = load(firstPageResponse);
const title = $firstPage('h1.p-title-value').text().trim();
const lastPageLink = $firstPage('ul.pageNav-main li.pageNav-page:last-child a').attr('href');
const totalPages = lastPageLink ? Number.parseInt(lastPageLink.match(/page-(\d+)/)?.[1] || '1', 10) : 1;
const extractPosts = ($: ReturnType<typeof load>): DataItem[] =>
$('article.message')
.toArray()
.flatMap((article) => {
const $article = $(article);
const postId = $article.attr('data-content')?.replace('post-', '');
if (!postId) {
return [];
}
const author = $article.find('.message-name a').text().trim();
const postDate = $article.find('time.u-dt').attr('datetime');
const content = $article.find('.bbWrapper').html() || '';
const postLink = `${threadLink}post-${postId}`;
// Get post number from the attribution list (e.g., "#717")
const postNumber = $article.find('.message-attribution-opposite--list li:last-child a').text().trim().replace('#', '') || postId;
return {
title: `#${postNumber} by ${author}`,
link: postLink,
guid: postLink,
description: processContent(content),
pubDate: postDate ? parseDate(postDate) : undefined,
author,
};
});
// Extract posts from the first page
const allPosts: DataItem[] = [...extractPosts($firstPage)];
// Fetch the last page if there are multiple pages
if (totalPages > 1) {
const lastPageResponse = await ofetch(`${threadLink}page-${totalPages}`, { headers });
allPosts.push(...extractPosts(load(lastPageResponse)));
}
return {
title: `[F95zone] ${title}`,
link: threadLink,
item: allPosts,
};
},
};

View File

@ -0,0 +1,68 @@
import { load } from 'cheerio';
const ALLOWED_TAGS = new Set(['div', 'span', 'p', 'br', 'b', 'strong', 'i', 'em', 'u', 's', 'a', 'img', 'ul', 'ol', 'li', 'blockquote', 'hr', 'pre', 'code']);
const ALLOWED_ATTRS: Record<string, string[]> = {
a: ['href', 'target', 'rel'],
img: ['src', 'alt', 'title', 'style'],
div: ['style'],
span: ['style'],
};
export const processContent = (html: string): string => {
const $ = load(html);
// Process images: use original URLs, remove duplicates
const seenImages = new Set<string>();
$('img').each((_, el) => {
const $img = $(el);
const $parent = $img.parent('a');
let src = $parent.attr('href') || $img.attr('data-src') || $img.attr('src') || '';
src = src.replace('/thumb/', '/');
if (!src || src.startsWith('data:') || seenImages.has(src)) {
$img.remove();
} else {
seenImages.add(src);
$img.attr('src', src).removeAttr('data-src');
if ($parent.length) {
$parent.replaceWith($img);
}
}
});
// Remove unwanted tags completely
$('button, script, style, noscript').remove();
// Remove disallowed tags but keep content
let changed = true;
while (changed) {
changed = false;
$('*').each((_, el) => {
if (el.type === 'tag' && !ALLOWED_TAGS.has(el.name)) {
$(el).replaceWith($(el).html() || '');
changed = true;
return false;
}
});
}
// Clean attributes
$('*').each((_, el) => {
if (el.type !== 'tag') {
return;
}
const allowed = new Set(ALLOWED_ATTRS[el.name] || []);
for (const attr of Object.keys(el.attribs || {})) {
if (!allowed.has(attr)) {
$(el).removeAttr(attr);
}
}
});
// Remove empty divs
$('div')
.filter((_, el) => !$(el).html()?.trim())
.remove();
return $.html() || '';
};