Compare commits

...

3 commits

Author SHA1 Message Date
ld3zian
859f839b66 fix? 2025-05-03 12:28:50 -07:00
ld3zian
94c02124f2 bruh 2025-05-03 12:24:55 -07:00
ld3zian
2d3eeeaeba puppet 2025-05-03 11:56:22 -07:00
5 changed files with 2283 additions and 3 deletions

1566
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -15,12 +15,16 @@
"@astrojs/tailwind": "^6.0.2",
"@astrojs/vercel": "^8.1.4",
"@floating-ui/dom": "^1.6.13",
"@sparticuz/chromium": "^133.0.0",
"@types/node": "^22.15.3",
"@vercel/analytics": "^1.5.0",
"astro": "^5.7.10",
"chrome-aws-lambda": "^10.1.0",
"feed": "^4.2.2",
"ioredis": "^5.6.1",
"p-throttle": "^7.0.0",
"puppeteer": "^24.8.0",
"puppeteer-core": "^10.1.0",
"redis-modules-sdk": "^1.4.0",
"tailwindcss": "^3.4.1"
}

View file

@ -0,0 +1,320 @@
import type { APIRoute } from "astro";
import puppeteerCore from 'puppeteer-core';
// Import @sparticuz/chromium for better serverless compatibility
import chromium from '@sparticuz/chromium';
// Check if running on Vercel
const IS_VERCEL = process.env.VERCEL === '1' || process.env.VERCEL === 'true';
// Import puppeteer dynamically for ESM compatibility
import puppeteer from 'puppeteer';
// Decide which Puppeteer to use based on environment
const puppeteerInstance = IS_VERCEL ? puppeteerCore : puppeteer;
// Helper function to extract comic slugs from a ComicK user list page
async function scrapeComicList(userId: string, limit: number = 10): Promise<string[]> {
let browser;
if (IS_VERCEL) {
// Use @sparticuz/chromium which is optimized for serverless environments
console.log('Running on Vercel - using @sparticuz/chromium with serverless settings');
// Configure @sparticuz/chromium for Vercel
await chromium.font('https://raw.githack.com/googlei18n/noto-emoji/master/fonts/NotoColorEmoji.ttf');
browser = await puppeteerCore.launch({
args: chromium.args,
defaultViewport: chromium.defaultViewport,
executablePath: await chromium.executablePath(), // Note: this is now a function call
headless: true, // Always use headless mode in serverless environment
ignoreHTTPSErrors: true,
});
} else {
// Use regular Puppeteer for local development
browser = await puppeteerInstance.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
}
try {
const page = await browser.newPage();
// Set a user agent to mimic a real browser
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36');
console.log(`Navigating to ComicK user page: ${userId}`);
// Navigate to the user's list page
await page.goto(`https://comick.io/user/${userId}/list`, {
waitUntil: 'networkidle0', // Wait until there are no network connections for at least 500ms
timeout: 60000 // Longer timeout to ensure everything loads
});
// Wait directly for the Next.js data script to load (the most reliable approach)
console.log('Waiting for Next.js data to load...');
try {
await page.waitForFunction(
() => document.getElementById('__NEXT_DATA__') !== null,
{ timeout: 10000 }
);
console.log('Next.js data script found!');
} catch (e) {
console.log('Next.js data script not found, page might not be fully loaded');
}
// Minimal page interaction - Next.js will load the data we need
console.log('Ensuring Next.js data is fully loaded...');
// Wait a bit for any dynamic data to populate
await new Promise(resolve => setTimeout(resolve, 3000));
// Verify Next.js data is available and complete
const dataLoaded = await page.evaluate(() => {
const script = document.getElementById('__NEXT_DATA__');
if (!script) return false;
try {
const data = JSON.parse(script.textContent || '{}');
// Check if the follows data is present
return !!data?.props?.pageProps?.follows;
} catch (e) {
return false;
}
});
if (!dataLoaded) {
console.log('Next.js data not fully loaded, waiting a bit longer...');
await new Promise(resolve => setTimeout(resolve, 3000));
} else {
console.log('Next.js data confirmed loaded!');
}
console.log('Page loaded, extracting comic data...');
// Extract the HTML structure for debugging
const pageHtml = await page.content();
console.log('Page HTML length:', pageHtml.length);
// Extract comic slugs using only Next.js data - no CSS selectors
const slugs = await page.evaluate(() => {
console.log('Extracting comics directly from Next.js data structure...');
try {
// Find the __NEXT_DATA__ script which contains all the comic data
const nextDataScript = document.getElementById('__NEXT_DATA__');
if (!nextDataScript) {
console.log('ERROR: __NEXT_DATA__ script not found!');
return [];
}
console.log('Found Next.js data script!');
const nextData = JSON.parse(nextDataScript.textContent || '{}');
// Navigate to the follows data in the structure
const follows = nextData?.props?.pageProps?.follows || [];
console.log(`Found ${follows.length} comics in Next.js data`);
if (follows.length > 0) {
// Extract slugs from the follows array
const comicSlugs = follows.map(follow => {
const comic = follow.md_comics;
if (comic && comic.slug) {
console.log(`Found comic: ${comic.title} with slug: ${comic.slug}`);
return comic.slug;
}
return null;
}).filter(Boolean);
console.log(`Successfully extracted ${comicSlugs.length} comics from Next.js data`);
return comicSlugs;
} else {
console.log('No follows data found in Next.js data');
// Try to locate comics in a different location in the data structure
const alternative = nextData?.props?.pageProps?.user_info?.user_comic_follow_count;
if (alternative) {
console.log(`Found user has ${alternative} comics according to user_info`);
}
return [];
}
} catch (e) {
console.log('Error extracting from Next.js data:', e);
return [];
}
});
// Remove duplicates by using a Set
const uniqueSlugs = Array.from(new Set(slugs));
console.log(`Extracted ${slugs.length} comics from page (${uniqueSlugs.length} unique)`);
// Limit the number of comics to the specified limit
const limitedSlugs = uniqueSlugs.slice(0, limit);
console.log(`Returning ${limitedSlugs.length} comics (limit: ${limit})`);
return limitedSlugs as string[];
} finally {
await browser.close();
}
}
// Define timeout for Vercel environment
const VERCEL_TIMEOUT = 10000; // 10 seconds for Vercel functions
const LOCAL_TIMEOUT = 60000; // 60 seconds for local development
const FUNCTION_TIMEOUT = IS_VERCEL ? VERCEL_TIMEOUT : LOCAL_TIMEOUT;
export const GET: APIRoute = async ({ request }) => {
// Ensure the function doesn't exceed Vercel's timeout limits
let timeoutId: NodeJS.Timeout;
// Create a promise that rejects after the timeout
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`Function timed out after ${FUNCTION_TIMEOUT}ms`));
}, FUNCTION_TIMEOUT);
});
try {
// Log environment information for debugging
console.log(`Environment: ${IS_VERCEL ? 'Vercel' : 'Local'}`);
if (IS_VERCEL) {
try {
console.log('Chrome executable path:', await chromium.executablePath());
console.log('Chrome args:', chromium.args);
} catch (e) {
console.log('Error getting Chrome executable path:', e);
}
}
const url = new URL(request.url);
const userId = url.searchParams.get('userId');
// Get limit parameter from query string (default to 10 if not provided)
const limitParam = url.searchParams.get('limit');
const limit = limitParam ? parseInt(limitParam, 10) : 10;
// Ensure limit is a valid number
const validLimit = isNaN(limit) || limit <= 0 ? 10 : Math.min(limit, 100);
if (!userId) {
return new Response(JSON.stringify({ error: 'Missing user ID' }), {
status: 400,
headers: {
'Content-Type': 'application/json'
}
});
}
// Extract user ID from URL if full URL was provided
let cleanUserId = userId;
if (userId.includes('comick.io/user/')) {
const match = userId.match(/comick\.io\/user\/([a-zA-Z0-9-]+)/);
if (match && match[1]) {
cleanUserId = match[1];
}
}
// Validate the UUID format
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(cleanUserId)) {
return new Response(JSON.stringify({ error: 'Invalid user ID format. Must be a UUID like: 8da7d6cf-a2ac-4972-a465-770ba975ee2a' }), {
status: 400,
headers: {
'Content-Type': 'application/json'
}
});
}
console.log(`Scraping comics for user: ${cleanUserId} with limit: ${validLimit}`);
// Use Puppeteer to scrape the user's comic list with the specified limit
const comics = await scrapeComicList(cleanUserId, validLimit);
// Calculate total
const totalComics = comics.length;
// Check if the list is empty
if (totalComics === 0) {
console.log('No comics found for user, returning empty list');
const response = new Response(JSON.stringify({
success: false,
error: "No comics found in this user's list. Make sure the user ID is correct and the list is not empty.",
data: {
comics: [],
total: 0,
limit: validLimit,
userId: cleanUserId
}
}), {
status: 404,
headers: {
'Content-Type': 'application/json'
}
});
// Clear the timeout to prevent memory leaks
clearTimeout(timeoutId);
return response;
}
console.log(`Returning ${totalComics} comics found for user ${cleanUserId}`);
const response = new Response(JSON.stringify({
success: true,
data: {
comics,
total: totalComics,
limit: validLimit,
userId: cleanUserId
}
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'max-age=3600' // Cache for 1 hour
}
});
// Clear the timeout to prevent memory leaks
clearTimeout(timeoutId);
return response;
} catch (error) {
console.error('Error fetching user comics:', error);
// Try to generate a helpful error message based on the error
let errorMessage = 'Failed to fetch user comics';
let statusCode = 500;
if (error instanceof Error) {
// Handle specific error types
if (error.message.includes('Failed to fetch') ||
error.message.includes('network') ||
error.message.includes('ECONNREFUSED')) {
errorMessage = 'Could not connect to ComicK API. Please try again later.';
} else if (error.message.includes('timeout')) {
errorMessage = 'Request timed out. The ComicK API might be experiencing high load.';
} else if (error.message.includes('parsing')) {
errorMessage = 'Received invalid data from ComicK API. Their API structure might have changed.';
}
}
// Clear the timeout to prevent memory leaks
clearTimeout(timeoutId);
return new Response(
JSON.stringify({
success: false,
error: errorMessage,
details: error instanceof Error ? error.message : String(error)
}), {
status: statusCode,
headers: {
'Content-Type': 'application/json'
}
}
);
}
};

View file

@ -38,6 +38,17 @@ const description = "Create a custom RSS feed for your favorite comics";
<div class="mt-4 sm:mt-8 text-center text-primary/60 text-sm sm:text-base">
<p>Search for your favorite comics using the search box above, or add their slugs manually.</p>
<p class="mt-2">Manual example: For <a href="https://comick.io/comic/01-one-piece" class="text-accent hover:underline">https://comick.io/comic/01-one-piece</a>, the slug is "01-one-piece"</p>
<div class="mt-4 flex justify-center">
<a href="/user-list" class="inline-flex items-center gap-2 px-4 py-2 bg-secondary hover:bg-hover rounded-md transition-colors font-medium text-sm sm:text-base">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
Generate RSS from ComicK User List
</a>
</div>
</div>
</main>
</div>

385
src/pages/user-list.astro Normal file
View file

@ -0,0 +1,385 @@
---
import Layout from '../layouts/Layout.astro';
const title = "ComicK - User List RSS Feed";
const description = "Create an RSS feed from your ComicK user list";
---
<Layout title={title} description={description}>
<div class="container mx-auto px-4 py-8">
<!-- Back button -->
<a
href="/en/sfw/all"
class="inline-flex items-center gap-2 mb-6 px-4 py-2 bg-secondary hover:bg-hover rounded-md transition-colors font-medium"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Back to Comics
</a>
<main class="mx-auto px-3 sm:px-4 lg:px-8 max-w-[800px] py-4 sm:py-8">
<h1 class="text-2xl sm:text-3xl lg:text-4xl font-bold text-center mb-4 sm:mb-8">User List RSS Feed</h1>
<div class="bg-secondary/50 p-3 sm:p-6 rounded-lg shadow-lg">
<div class="space-y-4 sm:space-y-6">
<div class="flex flex-col gap-3 sm:gap-4">
<label for="user-id-input" class="text-lg font-medium">ComicK User ID</label>
<div class="flex gap-2">
<input
type="text"
id="user-id-input"
placeholder="Enter your ComicK user ID..."
class="flex-1 px-3 sm:px-4 py-2 bg-primary rounded-lg border border-hover/30 focus:outline-none focus:border-accent/50 transition-colors text-sm sm:text-base"
/>
<button
id="fetch-user-list"
class="px-4 py-2 bg-secondary hover:bg-hover transition-colors rounded-lg text-sm sm:text-base flex items-center gap-2"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 12c0 1.2-4 6-9 6s-9-4.8-9-6c0-1.2 4-6 9-6s9 4.8 9 6z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
Fetch Comics
</button>
</div>
<p class="text-sm text-primary/60">
Example: For <a href="https://comick.io/user/8da7d6cf-a2ac-4972-a465-770ba975ee2a/list" class="text-accent hover:underline">https://comick.io/user/8da7d6cf-a2ac-4972-a465-770ba975ee2a/list</a>,
the user ID is "8da7d6cf-a2ac-4972-a465-770ba975ee2a"
</p>
</div>
<div class="mt-4">
<select
id="lang-select"
class="w-full px-3 sm:px-4 py-2 bg-primary rounded-lg border border-hover/30 focus:outline-none focus:border-accent/50 transition-colors text-sm sm:text-base"
>
<option value="en">English</option>
<option value="jp">Japanese</option>
<option value="ko">Korean</option>
<option value="zh">Chinese</option>
<option value="fr">French</option>
<option value="es">Spanish</option>
<option value="id">Indonesian</option>
<option value="th">Thai</option>
<option value="vi">Vietnamese</option>
<option value="de">German</option>
<option value="it">Italian</option>
<option value="ru">Russian</option>
<option value="pt-br">Portuguese (Brazil)</option>
</select>
<p class="text-sm text-primary/60 mt-1">
Select your preferred language for the RSS feed
</p>
</div>
<div id="loading-indicator" class="hidden">
<div class="flex items-center justify-center p-4">
<svg class="w-8 h-8 animate-spin text-accent" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="ml-3 text-lg">Fetching comics...</span>
</div>
</div>
<div id="error-message" class="hidden">
<div class="bg-red-500/10 border border-red-500/30 text-red-500 p-4 rounded-lg">
<p id="error-text">Error message will appear here</p>
</div>
</div>
<div id="comic-list" class="hidden space-y-2">
<h2 class="text-lg font-medium">Comics from Your List</h2>
<!-- Comics will be populated here -->
</div>
<div class="flex justify-center mt-4">
<button
id="generate-rss"
disabled
class="inline-flex items-center gap-2 px-6 py-3 bg-[rgb(var(--accent-bg))] text-[rgb(var(--accent-text))] rounded-lg hover:bg-[rgb(var(--accent-light))] transition-colors opacity-50 cursor-not-allowed"
>
<svg
class="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 11a9 9 0 0 1 9 9"></path>
<path d="M4 4a16 16 0 0 1 16 16"></path>
<circle cx="5" cy="19" r="1"></circle>
</svg>
Generate RSS Feed
</button>
</div>
</div>
</div>
<div class="mt-8 text-center text-primary/60 text-sm sm:text-base">
<p>This tool fetches comics from your ComicK user list and creates an RSS feed to track new chapter releases.</p>
<p class="mt-2">The feed will update when new chapters are released for any of the comics in your list.</p>
</div>
</main>
</div>
</Layout>
<script>
const userIdInput = document.getElementById("user-id-input") as HTMLInputElement;
const fetchButton = document.getElementById("fetch-user-list") as HTMLButtonElement;
const langSelect = document.getElementById("lang-select") as HTMLSelectElement;
const comicList = document.getElementById("comic-list") as HTMLDivElement;
const loadingIndicator = document.getElementById("loading-indicator") as HTMLDivElement;
const errorMessage = document.getElementById("error-message") as HTMLDivElement;
const errorText = document.getElementById("error-text") as HTMLParagraphElement;
const generateRssButton = document.getElementById("generate-rss") as HTMLButtonElement;
// Store fetched comics
let fetchedComicSlugs: string[] = [];
fetchButton?.addEventListener("click", async () => {
const userId = userIdInput.value.trim();
if (!userId) {
showError("Please enter a valid ComicK user ID");
return;
}
// Extract user ID if full URL was pasted
const extractedId = extractUserId(userId);
if (extractedId !== userId) {
userIdInput.value = extractedId;
}
// Show loading state
loadingIndicator.classList.remove("hidden");
comicList.classList.add("hidden");
errorMessage.classList.add("hidden");
fetchButton.disabled = true;
generateRssButton.disabled = true;
generateRssButton.classList.add("opacity-50", "cursor-not-allowed");
try {
const response = await fetch(`/api/user-comics?userId=${encodeURIComponent(extractedId)}`);
if (!response.ok) {
throw new Error(`Error ${response.status}: ${await response.text()}`);
}
const data = await response.json();
fetchedComicSlugs = data.comics || [];
if (fetchedComicSlugs.length === 0) {
showError("No comics found in this user's list. Make sure the user ID is correct and the list is not empty.");
return;
}
// Render the comic list
await renderComicList(fetchedComicSlugs);
// Enable the generate button
generateRssButton.disabled = false;
generateRssButton.classList.remove("opacity-50", "cursor-not-allowed");
// Add click handler for generate button
generateRssButton.onclick = generateRssFeed;
} catch (error) {
console.error("Failed to fetch user comics:", error);
showError(error instanceof Error ? error.message : "Failed to fetch comics from ComicK");
} finally {
loadingIndicator.classList.add("hidden");
fetchButton.disabled = false;
}
});
function extractUserId(input: string): string {
// Check if input is a full URL
if (input.includes('comick.io/user/')) {
const match = input.match(/comick\.io\/user\/([a-zA-Z0-9-]+)/);
if (match && match[1]) {
return match[1];
}
}
return input;
}
function showError(message: string) {
errorText.textContent = message;
errorMessage.classList.remove("hidden");
loadingIndicator.classList.add("hidden");
}
async function renderComicList(slugs: string[]) {
comicList.innerHTML = `<h2 class="text-lg font-medium">Comics from Your List (${slugs.length})</h2>`;
comicList.classList.remove("hidden");
// Show the first few comics with details
const comicsContainer = document.createElement("div");
comicsContainer.className = "mt-4 space-y-2";
// Create a display for the number of comics
const comicCount = document.createElement("div");
comicCount.className = "p-3 bg-primary rounded-lg border border-hover/30";
comicCount.textContent = `${slugs.length} comics found in this user's list`;
comicsContainer.appendChild(comicCount);
// Get details for the first 5 comics as a preview
const previewSlugs = slugs.slice(0, 5);
// Show loading state for the preview comics
const previewContainer = document.createElement("div");
previewContainer.className = "space-y-2";
previewContainer.innerHTML = `
<div class="p-3 bg-primary rounded-lg border border-hover/30">
Loading comic preview...
</div>
`;
comicsContainer.appendChild(previewContainer);
comicList.appendChild(comicsContainer);
try {
// Fetch details for the preview comics
const promises = previewSlugs.map(async (slug) => {
try {
const response = await fetch(`/api/comic/${encodeURIComponent(slug)}`);
if (!response.ok) return { slug, error: true };
return { slug, data: await response.json(), error: false };
} catch (error) {
return { slug, error: true };
}
});
const results = await Promise.all(promises);
// Replace the loading state with actual preview
previewContainer.innerHTML = "";
if (results.some(r => !r.error)) {
previewContainer.innerHTML = "<h3 class='text-md font-medium mb-2'>Preview of comics in your list:</h3>";
}
// Add preview items
results.forEach(result => {
const div = document.createElement("div");
div.className = "flex items-center gap-3 p-3 bg-primary rounded-lg border border-hover/30";
if (result.error) {
div.innerHTML = `<span>${result.slug}</span>`;
} else {
const comic = result.data.comic;
const coverUrl = comic?.md_covers?.[0]?.b2key
? `https://meo.comick.pictures/${comic.md_covers[0].b2key}`
: "/placeholder.png";
div.innerHTML = `
<img src="${coverUrl}" alt="${comic?.title || result.slug}" class="w-12 h-16 object-cover rounded">
<div>
<div class="font-medium">${comic?.title || result.slug}</div>
<div class="text-sm text-primary/60">${result.slug}</div>
</div>
`;
}
previewContainer.appendChild(div);
});
// Add note about more comics
if (slugs.length > 5) {
const moreComicsNote = document.createElement("div");
moreComicsNote.className = "text-sm text-primary/60 mt-2";
moreComicsNote.textContent = `...and ${slugs.length - 5} more comics`;
previewContainer.appendChild(moreComicsNote);
}
} catch (error) {
console.error("Failed to fetch comic details:", error);
previewContainer.innerHTML = `
<div class="p-3 bg-primary rounded-lg border border-red-500/30 text-red-500">
Failed to load comic preview details.
</div>
`;
}
}
async function generateRssFeed(e: MouseEvent) {
e.preventDefault();
if (fetchedComicSlugs.length === 0) {
showError("No comics available to generate feed");
return;
}
const lang = langSelect.value;
// Update button to loading state
generateRssButton.disabled = true;
generateRssButton.innerHTML = `
<svg class="w-5 h-5 animate-spin" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" stroke-linecap="round"/>
</svg>
Generating...
`;
try {
const response = await fetch("/api/create-feed", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
slugs: fetchedComicSlugs,
lang,
source: "user-list",
}),
});
if (!response.ok) {
throw new Error("Failed to create feed");
}
const { feedId } = await response.json();
// Navigate to the feed
window.location.href = `/rss/${feedId}.xml`;
} catch (error) {
console.error("Failed to generate feed:", error);
showError(error instanceof Error ? error.message : "Failed to generate RSS feed");
// Reset button
generateRssButton.disabled = false;
generateRssButton.innerHTML = `
<svg
class="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M4 11a9 9 0 0 1 9 9"></path>
<path d="M4 4a16 16 0 0 1 16 16"></path>
<circle cx="5" cy="19" r="1"></circle>
</svg>
Generate RSS Feed
`;
}
}
</script>