mirror of
https://github.com/ld3z/new-manga.git
synced 2026-11-09 20:33:44 -07:00
push change
This commit is contained in:
parent
bc0f10ab1d
commit
b103a24643
9 changed files with 557 additions and 3 deletions
|
|
@ -26,6 +26,9 @@ const contentTypes = new Set(["sfw", "nsfw"]);
|
|||
const comicTypes = new Set(['manga', 'manhwa', 'manhua', 'all']);
|
||||
const urlBase = "https://api.comick.fun/chapter";
|
||||
|
||||
// Use import.meta.env for Astro environment variables
|
||||
const SCRAPE_API_BASE_URL = import.meta.env.SCRAPE_API_BASE_URL || 'http://localhost:8000';
|
||||
|
||||
let genreCache: Genre[] = [];
|
||||
|
||||
const RETRY_COUNT = 3;
|
||||
|
|
@ -180,6 +183,31 @@ interface ChapterParams {
|
|||
lang?: string;
|
||||
}
|
||||
|
||||
interface UserLastReadChapter {
|
||||
hid: string;
|
||||
chap: string;
|
||||
lang: string;
|
||||
vol: string | null;
|
||||
}
|
||||
|
||||
interface ComicInfo {
|
||||
slug: string;
|
||||
user_last_read_chapter: UserLastReadChapter | null;
|
||||
comic_latest_chapter: number;
|
||||
user_read_at: string;
|
||||
}
|
||||
|
||||
interface ScrapeData {
|
||||
user_id: string;
|
||||
limit: number;
|
||||
comics: ComicInfo[];
|
||||
}
|
||||
|
||||
interface ScrapeApiResponse {
|
||||
success: boolean;
|
||||
data: ScrapeData;
|
||||
}
|
||||
|
||||
export async function getComicBySlug(slug: string): Promise<ComicDetail | null> {
|
||||
try {
|
||||
const response = await throttledFetch(`https://api.comick.fun/comic/${slug}`);
|
||||
|
|
@ -293,7 +321,7 @@ export async function getChaptersForSlugs(
|
|||
console.log(`Fetching chapters for ${slugs.length} comics`);
|
||||
const { client: redis } = await getRedisClient();
|
||||
|
||||
const uniqueSlugs = [...new Set(slugs)];
|
||||
const uniqueSlugs = Array.from(new Set(slugs));
|
||||
const pipeline = redis.pipeline();
|
||||
|
||||
// Check cache for all slugs
|
||||
|
|
@ -426,4 +454,40 @@ export async function validateSlugs(slugs: string[]): Promise<string[]> {
|
|||
}
|
||||
|
||||
return validSlugs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the comic list for a specific user from the scrape endpoint.
|
||||
* @param userId The ID of the user.
|
||||
* @param limit The maximum number of comics to fetch.
|
||||
* @returns A promise that resolves to the scrape API response data.
|
||||
*/
|
||||
export async function fetchUserComics(userId: string, limit: number = 10): Promise<ScrapeData> {
|
||||
const url = `${SCRAPE_API_BASE_URL}/scrape/${userId}?limit=${limit}`;
|
||||
try {
|
||||
// Using the existing throttledFetch for consistency, though rate limits might differ for local API
|
||||
const response = await throttledFetch(url);
|
||||
const result: ScrapeApiResponse = await response.json();
|
||||
if (!result.success) {
|
||||
// Handle API-level failure indication
|
||||
throw new Error(`Scrape API indicated failure for user ${userId}`);
|
||||
}
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch user comics for ${userId}:`, error);
|
||||
// Re-throw or return a default/error state depending on your error handling strategy
|
||||
if (error instanceof Error) {
|
||||
throw new APIError(
|
||||
`Failed to fetch user comics: ${error.message}`,
|
||||
error instanceof Response ? error.status : undefined, // Use Response type guard if available
|
||||
url
|
||||
);
|
||||
} else {
|
||||
throw new APIError(
|
||||
'An unknown error occurred while fetching user comics',
|
||||
undefined,
|
||||
url
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1113,6 +1113,60 @@ async function syncFromReplicaToPrimary(replicaUrl: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
// Add user feed mapping functions
|
||||
interface UserFeedMapping {
|
||||
userId: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function storeUserFeedMapping(
|
||||
feedId: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
const mapping: UserFeedMapping = {
|
||||
userId,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
const stringValue = JSON.stringify(mapping);
|
||||
const regularKey = `user:${feedId}`;
|
||||
|
||||
try {
|
||||
const { client: redis, url: activeUrl } = await getRedisClient();
|
||||
const clientName = getRedisName(activeUrl);
|
||||
|
||||
await redis.set(regularKey, stringValue, "EX", DEFAULT_CACHE_TTL);
|
||||
await ensurePrimaryAndReplicate(activeUrl, regularKey, stringValue, DEFAULT_CACHE_TTL);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error storing user feed mapping for ${feedId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserFeedMapping(
|
||||
feedId: string
|
||||
): Promise<UserFeedMapping | null> {
|
||||
try {
|
||||
const { client } = await getRedisClient();
|
||||
const regularKey = `user:${feedId}`;
|
||||
const jsonString = await client.get(regularKey);
|
||||
|
||||
if (!jsonString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = JSON.parse(jsonString);
|
||||
return {
|
||||
userId: data.userId,
|
||||
created_at: data.created_at
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error retrieving user feed mapping for ${feedId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize connections eagerly to establish connections as soon as possible
|
||||
if (!import.meta.env.SSR) {
|
||||
// Trigger initialization but don't block module loading
|
||||
|
|
|
|||
28
src/pages/api/create-user-feed.ts
Normal file
28
src/pages/api/create-user-feed.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { APIRoute } from "astro";
|
||||
import { createHash } from 'crypto';
|
||||
import { storeUserFeedMapping } from '../../lib/db';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { userId } = await request.json();
|
||||
|
||||
if (!userId || typeof userId !== 'string') {
|
||||
return new Response("Invalid user ID", { status: 400 });
|
||||
}
|
||||
|
||||
// Create a unique hash based on userId
|
||||
const hash = createHash('md5').update(userId).digest('hex').slice(0, 8);
|
||||
|
||||
// Store the mapping in Redis
|
||||
await storeUserFeedMapping(hash, userId);
|
||||
|
||||
return new Response(JSON.stringify({ feedId: hash }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response("Internal server error", { status: 500 });
|
||||
}
|
||||
};
|
||||
|
|
@ -35,10 +35,24 @@ const description = "Create a custom RSS feed for your favorite comics";
|
|||
<SlugInput />
|
||||
</div>
|
||||
|
||||
<!-- Add section for User Feed link -->
|
||||
<div class="mt-8 bg-secondary/50 p-3 sm:p-6 rounded-lg shadow-lg text-center">
|
||||
<h2 class="text-xl font-semibold mb-3">Generate Feed from User List</h2>
|
||||
<p class="mb-4 text-primary/80">
|
||||
Alternatively, generate an RSS feed based on a specific Comick user's reading list.
|
||||
</p>
|
||||
<a
|
||||
href="/user-feed"
|
||||
class="inline-block px-6 py-2 bg-accent text-accent-text rounded-md hover:bg-accent-dark transition-colors font-medium"
|
||||
>
|
||||
Go to User Feed Generator
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
</Layout>
|
||||
</Layout>
|
||||
140
src/pages/rss/user/[userId].xml.ts
Normal file
140
src/pages/rss/user/[userId].xml.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { Feed } from 'feed';
|
||||
import type { APIRoute } from 'astro';
|
||||
import { fetchUserComics, getComicBySlug, APIError } from '../../../lib/api';
|
||||
import { getUserFeedMapping } from '../../../lib/db';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
export const GET: APIRoute = async ({ params, site }) => {
|
||||
const feedId = params.userId?.replace('.xml', '');
|
||||
|
||||
if (!feedId) {
|
||||
return new Response("Feed ID not provided", { status: 400 });
|
||||
}
|
||||
|
||||
if (!site) {
|
||||
return new Response("Site configuration is missing", { status: 500 });
|
||||
}
|
||||
|
||||
try {
|
||||
const mapping = await getUserFeedMapping(feedId);
|
||||
|
||||
if (!mapping) {
|
||||
return new Response("Feed not found", { status: 404 });
|
||||
}
|
||||
|
||||
const userId = mapping.userId;
|
||||
const userData = await fetchUserComics(userId);
|
||||
const now = new Date();
|
||||
|
||||
// Store all items first so we can sort them
|
||||
const feedItems = [];
|
||||
let mostRecentDate = new Date(0);
|
||||
|
||||
const comicDetailsPromises = userData.comics
|
||||
.filter(comic => comic.user_last_read_chapter !== null)
|
||||
.map(async (comic) => {
|
||||
try {
|
||||
const details = await getComicBySlug(comic.slug);
|
||||
if (comic.user_read_at) {
|
||||
const readDate = new Date(comic.user_read_at);
|
||||
if (!isNaN(readDate.getTime()) && readDate > mostRecentDate) {
|
||||
mostRecentDate = readDate;
|
||||
}
|
||||
}
|
||||
return { comic, details };
|
||||
} catch (error) {
|
||||
return { comic, details: null };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(comicDetailsPromises);
|
||||
|
||||
const feed = new Feed({
|
||||
title: `Comick User Read Feed - ${userId}`,
|
||||
description: `Recently read chapters by Comick user ${userId}`,
|
||||
id: `${site}rss/user/${feedId}.xml`,
|
||||
link: site.toString(),
|
||||
language: "en",
|
||||
copyright: `All rights reserved`,
|
||||
updated: mostRecentDate > new Date(0) ? mostRecentDate : now,
|
||||
generator: "ComicK RSS Generator",
|
||||
feedLinks: {
|
||||
rss2: `${site}rss/user/${feedId}.xml`
|
||||
},
|
||||
});
|
||||
|
||||
for (const { comic, details } of results) {
|
||||
if (comic.user_last_read_chapter && details?.comic) {
|
||||
const comicTitle = details.comic.title;
|
||||
const chapterNumber = comic.user_last_read_chapter.chap;
|
||||
const itemTitle = sanitizeHtml(comicTitle);
|
||||
const itemDescription = `User read chapter ${chapterNumber}`;
|
||||
const itemLink = `https://comick.fun/comic/${comic.slug}`;
|
||||
|
||||
let readDate;
|
||||
if (comic.user_read_at) {
|
||||
readDate = new Date(comic.user_read_at);
|
||||
if (isNaN(readDate.getTime())) {
|
||||
readDate = now;
|
||||
}
|
||||
} else {
|
||||
readDate = now;
|
||||
}
|
||||
|
||||
feedItems.push({
|
||||
title: itemTitle,
|
||||
id: `${itemLink}#read-chapter-${chapterNumber}-${readDate.getTime()}`,
|
||||
link: itemLink,
|
||||
description: itemDescription,
|
||||
date: readDate,
|
||||
pubDate: readDate
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
feedItems.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
feedItems.forEach(item => feed.addItem(item));
|
||||
|
||||
return new Response(feed.rss2(), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=600'
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
let status = 500;
|
||||
let message = 'Failed to generate RSS feed.';
|
||||
|
||||
if (error instanceof APIError) {
|
||||
message = error.message;
|
||||
status = error.statusCode === 404 ? 404 : (error.statusCode || 500);
|
||||
if (status === 404) message = 'Comick user not found or scrape failed.';
|
||||
} else if (error instanceof Error) {
|
||||
message = error.message;
|
||||
}
|
||||
|
||||
const errorFeed = new Feed({
|
||||
title: "Error Generating Feed",
|
||||
description: message,
|
||||
id: site.toString(),
|
||||
link: site.toString(),
|
||||
language: "en",
|
||||
copyright: ""
|
||||
});
|
||||
|
||||
errorFeed.addItem({
|
||||
title: `Error ${status}`,
|
||||
description: message,
|
||||
link: site.toString(),
|
||||
date: new Date(),
|
||||
id: `${site}error/${status}/${new Date().getTime()}`
|
||||
});
|
||||
|
||||
return new Response(errorFeed.rss2(), {
|
||||
status: status,
|
||||
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
};
|
||||
136
src/pages/user-feed.astro
Normal file
136
src/pages/user-feed.astro
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
---
|
||||
|
||||
<Layout title="Generate User RSS Feed">
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<!-- Back button -->
|
||||
<a
|
||||
href="/custom"
|
||||
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 Custom Feed
|
||||
</a>
|
||||
|
||||
<div 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">Generate Comick User RSS Feed</h1>
|
||||
<p class="mb-6 text-center text-primary/80">
|
||||
Enter a Comick User ID below to generate an RSS feed link for their recently read chapters.
|
||||
</p>
|
||||
|
||||
<div class="max-w-2xl mx-auto bg-secondary/50 p-6 rounded-lg shadow-lg">
|
||||
<label for="userIdInput" class="block text-sm font-medium text-primary mb-2">Comick User ID:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="userIdInput"
|
||||
placeholder="e.g., 4e996622-f879-4fd1-ba1e-dade02600555"
|
||||
class="w-full px-3 py-2 bg-secondary border border-hover rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent mb-4 text-primary placeholder-primary/60"
|
||||
/>
|
||||
<button
|
||||
id="generateButton"
|
||||
class="w-full bg-accent text-accent-text font-medium py-2 px-4 rounded hover:bg-accent-dark transition-colors focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
>
|
||||
Generate Feed Link
|
||||
</button>
|
||||
|
||||
<div id="feedLinkContainer" class="mt-4 text-center" style="display: none;">
|
||||
<p class="text-sm text-primary/80 mb-2">Your RSS Feed Link:</p>
|
||||
<a id="feedLink" href="#" target="_blank" class="text-accent hover:underline break-all"></a>
|
||||
<button
|
||||
id="copyButton"
|
||||
class="ml-2 px-3 py-1 text-sm bg-secondary hover:bg-hover rounded transition-colors focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<p id="copyMessage" class="text-xs text-green-500 mt-2" style="display: none;">Copied to clipboard!</p>
|
||||
</div>
|
||||
<p id="errorMessage" class="text-red-500 text-sm mt-2 text-center" style="display: none;"></p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center text-primary/60 text-sm">
|
||||
<p>This will generate an RSS feed of the user's recently read chapters.</p>
|
||||
<p class="mt-2">The User ID can be found in their Comick profile URL.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const userIdInput = document.getElementById('userIdInput') as HTMLInputElement;
|
||||
const generateButton = document.getElementById('generateButton') as HTMLButtonElement;
|
||||
const feedLinkContainer = document.getElementById('feedLinkContainer')!;
|
||||
const feedLink = document.getElementById('feedLink') as HTMLAnchorElement;
|
||||
const copyButton = document.getElementById('copyButton') as HTMLButtonElement;
|
||||
const copyMessage = document.getElementById('copyMessage')!;
|
||||
const errorMessage = document.getElementById('errorMessage')!;
|
||||
|
||||
const userIdRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||
|
||||
generateButton.addEventListener('click', async () => {
|
||||
const userId = userIdInput.value.trim();
|
||||
errorMessage.style.display = 'none';
|
||||
feedLinkContainer.style.display = 'none';
|
||||
|
||||
if (!userId) {
|
||||
errorMessage.textContent = 'Please enter a User ID.';
|
||||
errorMessage.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userIdRegex.test(userId)) {
|
||||
errorMessage.textContent = 'Invalid User ID format. Please enter a valid UUID.';
|
||||
errorMessage.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/create-user-feed', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate feed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const feedUrl = `${window.location.origin}/rss/user/${data.feedId}.xml`;
|
||||
feedLink.href = feedUrl;
|
||||
feedLink.textContent = feedUrl;
|
||||
feedLinkContainer.style.display = 'block';
|
||||
copyMessage.style.display = 'none';
|
||||
|
||||
} catch (error) {
|
||||
errorMessage.textContent = 'Failed to generate feed. Please try again.';
|
||||
errorMessage.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
copyButton.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(feedLink.href);
|
||||
copyMessage.style.display = 'block';
|
||||
setTimeout(() => {
|
||||
copyMessage.style.display = 'none';
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
errorMessage.textContent = 'Failed to copy to clipboard';
|
||||
errorMessage.style.display = 'block';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</Layout>
|
||||
Loading…
Add table
Add a link
Reference in a new issue