mirror of
https://github.com/ld3z/new-manga.git
synced 2026-11-09 21:03:45 -07:00
Compare commits
3 commits
bc0f10ab1d
...
859f839b66
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
859f839b66 | ||
|
|
94c02124f2 | ||
|
|
2d3eeeaeba |
5 changed files with 2283 additions and 3 deletions
1566
package-lock.json
generated
1566
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
320
src/pages/api/user-comics.ts
Normal file
320
src/pages/api/user-comics.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -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
385
src/pages/user-list.astro
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue