feat: 初始化项目,支持流式搜索与限流

*   初始化项目结构,包括配置、依赖和忽略文件。
*   引入核心搜索逻辑,支持流式响应以提供实时进度。
*   抽象化平台接口,并集成多个Galgame和补丁搜索源。
*   实现基于IP的速率限制功能,利用Cloudflare KV存储。
*   新增自动化脚本,用于生成平台索引文件。
*   统一HTTP请求客户端,增加超时和自定义User-Agent。
*   为部分平台添加了对`zypassword`参数的支持。
This commit is contained in:
Jurangren
2025-08-21 21:22:54 +08:00
parent 6708dcc84f
commit 83661b404a
47 changed files with 4338 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
# Node.js dependencies
node_modules/
# Wrangler CLI temporary files
.wrangler/
# Build output (if any)
# dist/
# build/
*.md
# Environment variables
.env
.env.*

1571
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "new-project",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"generate": "node scripts/generate-indices.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@cloudflare/workers-types": "^4.20250821.0",
"typescript": "^5.9.2",
"wrangler": "^4.31.0"
}
}

View File

@@ -0,0 +1,39 @@
const fs = require('fs');
const path = require('path');
const platformsDir = path.join(__dirname, '../src/platforms');
function generateIndexFile(directory) {
const dirPath = path.join(platformsDir, directory);
if (!fs.existsSync(dirPath)) return;
const files = fs.readdirSync(dirPath)
.filter(file => file.endsWith('.ts') && file !== 'index.ts');
if (files.length === 0) return;
const imports = files.map(file => {
const platformName = path.basename(file, '.ts');
return `import ${platformName} from "./${platformName}";`;
}).join('\n');
const platformNames = files.map(file => path.basename(file, '.ts'));
const content = `import type { Platform } from "../../types";
${imports}
const platforms: Platform[] = [
${platformNames.join(',\n ')},
];
export default platforms;
`;
fs.writeFileSync(path.join(dirPath, 'index.ts'), content.trim() + '\n');
console.log(`Generated index for ${directory} with ${files.length} platforms.`);
}
console.log('Generating platform indices...');
generateIndexFile('gal');
generateIndexFile('patch');
console.log('Done.');

69
src/core.ts Normal file
View File

@@ -0,0 +1,69 @@
import type { Platform, PlatformSearchResult, StreamProgress, StreamResult } from "./types";
import platformsGal from "./platforms/gal";
import platformsPatch from "./platforms/patch";
/**
* 将平台搜索结果格式化为自定义的流事件字符串 (JSON + 换行符)。
*/
function formatStreamEvent(data: object): string {
return `${JSON.stringify(data)}\n`;
}
/**
* 处理搜索请求并以流的形式写入结果。
* @param game 要搜索的游戏名称。
* @param platforms 要使用的平台列表。
* @param writer 用于写入 SSE 事件的 WritableStreamDefaultWriter。
* @param zypassword (可选) 访问某些平台可能需要的密码。
*/
export async function handleSearchRequestStream(
game: string,
platforms: Platform[],
writer: WritableStreamDefaultWriter<Uint8Array>,
zypassword: string = "" // 添加 zypassword 参数
): Promise<void> {
const encoder = new TextEncoder();
const total = platforms.length;
let completed = 0;
// 发送初始的总数信息
await writer.write(encoder.encode(formatStreamEvent({ total })));
const searchPromises = platforms.map(async (platform) => {
try {
// 传递 zypassword 给平台搜索函数
const result = await platform.search(game, zypassword);
completed++;
const progress: StreamProgress = { completed, total };
if (result.count > 0 || result.error) {
const streamResult: StreamResult = {
name: result.name,
color: result.error ? 'red' : platform.color,
items: result.items,
error: result.error,
};
await writer.write(encoder.encode(formatStreamEvent({ progress, result: streamResult })));
} else {
// 即使没有结果或错误,也发送进度更新
await writer.write(encoder.encode(formatStreamEvent({ progress })));
}
} catch (e) {
completed++;
// 记录平台内部的未知错误
console.error(`Error searching platform ${platform.name}:`, e);
const progress: StreamProgress = { completed, total };
await writer.write(encoder.encode(formatStreamEvent({ progress })));
}
});
// 等待所有搜索完成
await Promise.all(searchPromises);
// 发送完成信号
await writer.write(encoder.encode(formatStreamEvent({ done: true })));
}
export const PLATFORMS_GAL = platformsGal;
export const PLATFORMS_PATCH = platformsPatch;

89
src/index.ts Normal file
View File

@@ -0,0 +1,89 @@
import { handleSearchRequestStream, PLATFORMS_GAL, PLATFORMS_PATCH } from "./core";
import { checkRateLimit } from "./ratelimit";
export interface Env {
RATE_LIMIT_KV: KVNamespace;
}
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
async function handleSearch(request: Request, env: Env, ctx: ExecutionContext, platforms: any[]) {
try {
const formData = await request.formData();
const game = formData.get("game") as string;
const zypassword = formData.get("zypassword") as string || ""; // 获取 zypassword
if (env.RATE_LIMIT_KV) {
const ip = request.headers.get("CF-Connecting-IP") || "unknown";
const { allowed, retryAfter } = await checkRateLimit(ip, env.RATE_LIMIT_KV);
if (!allowed) {
return new Response(
JSON.stringify({ error: `搜索过于频繁, 请 ${retryAfter} 秒后再试` }),
{
status: 429,
headers: { "Content-Type": "application/json", ...corsHeaders },
}
);
}
}
if (!game || typeof game !== 'string') {
return new Response(JSON.stringify({ error: "Game name is required" }), {
status: 400,
headers: { "Content-Type": "application/json", ...corsHeaders },
});
}
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
// 将异步任务交给 waitUntil 来处理,确保它能完整执行
ctx.waitUntil(
handleSearchRequestStream(game.trim(), platforms, writer, zypassword) // 传递 zypassword
.catch(err => console.error("Streaming error:", err))
.finally(() => writer.close())
);
return new Response(readable, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
...corsHeaders
},
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred";
return new Response(JSON.stringify({ error: errorMessage }), {
status: 500,
headers: { "Content-Type": "application/json", ...corsHeaders },
});
}
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (request.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
if (request.method === "POST") {
if (url.pathname === "/gal") {
return handleSearch(request, env, ctx, PLATFORMS_GAL);
}
if (url.pathname === "/patch") {
return handleSearch(request, env, ctx, PLATFORMS_PATCH);
}
}
return new Response("Not Found", { status: 404 });
},
};

View File

@@ -0,0 +1,58 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://acgyyg.ru/";
const REGEX = /<a target="_blank" href="(?<URL>.*?)" title="(?<NAME>.*?)" class="post-overlay">/gs;
async function searchACGYingYingGuai(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "ACG嘤嘤怪",
count: 0,
items: [],
};
try {
const url = new URL(API_URL);
url.searchParams.set("s", game);
const response = await fetchClient(url);
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const html = await response.text();
const matches = html.matchAll(REGEX);
const items: SearchResultItem[] = [];
for (const match of matches) {
if (match.groups?.NAME && match.groups?.URL) {
items.push({
name: match.groups.NAME.trim(),
url: match.groups.URL,
});
}
}
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const ACGYingYingGuai: Platform = {
name: "ACG嘤嘤怪",
color: "white",
magic: false,
search: searchACGYingYingGuai,
};
export default ACGYingYingGuai;

View File

@@ -0,0 +1,58 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://seve.yugal.cc";
const REGEX = /<div class="post-info">\s*?<h2><a href="(?<URL>.*?)">(?<NAME>.*?)<\/a><\/h2>/gs;
async function searchBiAnXingLu(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "彼岸星露",
count: 0,
items: [],
};
try {
const url = new URL(API_URL);
url.searchParams.set("s", game);
const response = await fetchClient(url);
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const html = await response.text();
const matches = html.matchAll(REGEX);
const items: SearchResultItem[] = [];
for (const match of matches) {
if (match.groups?.NAME && match.groups?.URL) {
items.push({
name: match.groups.NAME.trim(),
url: match.groups.URL,
});
}
}
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const BiAnXingLu: Platform = {
name: "彼岸星露",
color: "lime",
magic: false,
search: searchBiAnXingLu,
};
export default BiAnXingLu;

View File

@@ -0,0 +1,70 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://inarigal.com/api/home/list";
const BASE_URL = "https://inarigal.com/detail/";
interface DaoHeGalItem {
id: number;
title_cn: string;
}
interface DaoHeGalResponse {
success: boolean;
data: {
list: DaoHeGalItem[];
};
}
async function searchDaoHeGal(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "稻荷GAL",
count: 0,
items: [],
};
try {
const url = new URL(API_URL);
url.searchParams.set("page", "1");
url.searchParams.set("pageSize", "18"); // Hardcoded as per original script
url.searchParams.set("search", game);
const response = await fetchClient(url);
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const data = await response.json() as DaoHeGalResponse;
if (!data.success) {
throw new Error("API returned success: false");
}
const items: SearchResultItem[] = data.data.list.map(item => ({
name: item.title_cn.trim(),
url: BASE_URL + item.id,
}));
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const DaoHeGal: Platform = {
name: "稻荷GAL",
color: "lime",
magic: false,
search: searchDaoHeGal,
};
export default DaoHeGal;

View File

@@ -0,0 +1,66 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://www.fufugal.com/so";
const BASE_URL = "https://www.fufugal.com/detail";
interface FuFuACGItem {
game_id: number;
game_name: string;
}
interface FuFuACGResponse {
obj: FuFuACGItem[];
}
async function searchFuFuACG(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "FuFuACG",
count: 0,
items: [],
};
try {
const url = new URL(API_URL);
url.searchParams.set("query", game);
const response = await fetchClient(url, {
headers: {
"Accept": "application/json, text/plain, */*",
}
});
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const data = await response.json() as FuFuACGResponse;
const items: SearchResultItem[] = data.obj.map(item => ({
name: item.game_name,
url: `${BASE_URL}?id=${item.game_id}`,
}));
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const FuFuACG: Platform = {
name: "FuFuACG",
color: "white",
magic: false,
search: searchFuFuACG,
};
export default FuFuACG;

View File

@@ -0,0 +1,63 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://www.ggbases.com/search.so";
const BASE_URL = "https://www.ggbases.com/view.so?id=";
const REGEX = /<a index=\d+ id="bid(?<URL>\d*?)" name="title" c=".*?" target="_blank" href=".*?">(?<NAME>.*?)<\/a>/gs;
async function searchGGBases(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "GGBases",
count: 0,
items: [],
};
try {
const url = new URL(API_URL);
url.searchParams.set("p", "0");
url.searchParams.set("title", game);
const response = await fetchClient(url);
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const html = await response.text();
// Pre-process the HTML to remove highlighting tags
const processedHtml = html.replace(/<\/b>/g, "").replace(/<b style='color:red'>/g, "");
const matches = processedHtml.matchAll(REGEX);
const items: SearchResultItem[] = [];
for (const match of matches) {
if (match.groups?.NAME && match.groups?.URL) {
items.push({
name: match.groups.NAME.trim(),
url: BASE_URL + match.groups.URL,
});
}
}
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const GGBases: Platform = {
name: "GGBases",
color: "lime",
magic: false,
search: searchGGBases,
};
export default GGBases;

56
src/platforms/gal/GGS.ts Normal file
View File

@@ -0,0 +1,56 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const DATA_URL = "https://gal.saop.cc/search.json";
const BASE_URL = "https://gal.saop.cc";
interface GgsItem {
title: string;
url: string;
}
async function searchGGS(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "GGS",
count: 0,
items: [],
};
try {
const response = await fetchClient(DATA_URL);
if (!response.ok) {
throw new Error(`Failed to fetch data from ${DATA_URL}`);
}
const data = await response.json() as GgsItem[];
const items: SearchResultItem[] = data
.filter(item => item.title.includes(game))
.map(item => ({
name: item.title,
url: BASE_URL + item.url,
}));
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const GGS: Platform = {
name: "GGS",
color: "lime",
magic: false,
search: searchGGS,
};
export default GGS;

View File

@@ -0,0 +1,88 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://gallibrary.pw/galgame/game/manyGame";
const BASE_URL = "https://gallibrary.pw/game.html?id=";
interface GalTuShuGuanItem {
id: number;
listGameText: { data: string; type: number; version: number }[]; // Corrected type
}
interface GalTuShuGuanResponse {
code: number;
data?: GalTuShuGuanItem[];
}
// A helper function to strip HTML tags from a string
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, "").trim();
}
async function searchGalTuShuGuan(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "GAL图书馆",
count: 0,
items: [],
};
try {
const url = new URL(API_URL);
url.searchParams.set("page", "1");
url.searchParams.set("type", "1");
url.searchParams.set("count", "1000"); // Hardcoded as per original script
url.searchParams.set("keyWord", game);
const response = await fetchClient(url);
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const data = await response.json() as GalTuShuGuanResponse;
// console.log("GAL图书馆 API Response Data:", JSON.stringify(data, null, 2)); // Keep for debugging if needed
if (data.code !== 200) {
throw new Error(`API returned code ${data.code}`);
}
if (!data.data) {
throw new Error("API response 'data' field is missing or null.");
}
const items: SearchResultItem[] = data.data.map(item => {
// Access item.listGameText[1].data and strip HTML
const name = item.listGameText[1]?.data; // Use optional chaining for safety
if (!name) {
console.warn(`GAL图书馆: Missing name data for item ID ${item.id}`);
return null; // Skip this item
}
return {
name: stripHtml(name),
url: BASE_URL + item.id,
};
}).filter(Boolean) as SearchResultItem[]; // Filter out nulls
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const GalTuShuGuan: Platform = {
name: "GAL图书馆",
color: "lime",
magic: false,
search: searchGalTuShuGuan,
};
export default GalTuShuGuan;

View File

@@ -0,0 +1,83 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://www.galgamex.net/api/search";
const BASE_URL = "https://www.galgamex.net/";
interface GalgameXItem {
name: string;
uniqueId: string;
}
interface GalgameXResponse {
galgames: GalgameXItem[];
}
async function searchGalgameX(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "Galgamex",
count: 0,
items: [],
};
try {
const payload = {
queryString: JSON.stringify([{ type: "keyword", name: game }]),
limit: 24, // Hardcoded as per original script
searchOption: {
searchInIntroduction: false,
searchInAlias: true,
searchInTag: false,
},
page: 1,
selectedType: "all",
selectedLanguage: "all",
selectedPlatform: "all",
sortField: "resource_update_time",
sortOrder: "desc",
selectedYears: ["all"],
selectedMonths: ["all"],
};
const response = await fetchClient(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const data = await response.json() as GalgameXResponse;
const items: SearchResultItem[] = data.galgames.map(item => ({
name: item.name.trim(),
url: BASE_URL + item.uniqueId,
}));
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const GalgameX: Platform = {
name: "Galgamex",
color: "lime",
magic: false,
search: searchGalgameX,
};
export default GalgameX;

View File

@@ -0,0 +1,58 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://www.hikarinagi.net/";
const REGEX = /" class="lazyload fit-cover radius8">.*?<h2 class="item-heading"><a target="_blank" href="(?<URL>.*?)">(?<NAME>.*?)<\/a><\/h2>/gs;
async function searchHikarinagi(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "Hikarinagi",
count: 0,
items: [],
};
try {
const url = new URL(API_URL);
url.searchParams.set("s", game);
const response = await fetchClient(url);
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const html = await response.text();
const matches = html.matchAll(REGEX);
const items: SearchResultItem[] = [];
for (const match of matches) {
if (match.groups?.NAME && match.groups?.URL) {
items.push({
name: match.groups.NAME.trim(),
url: match.groups.URL,
});
}
}
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const Hikarinagi: Platform = {
name: "Hikarinagi",
color: "white",
magic: false,
search: searchHikarinagi,
};
export default Hikarinagi;

View File

@@ -0,0 +1,70 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://game.acgs.one/api/posts";
interface JiMengACGItem {
title: string;
permalink: string;
}
interface JiMengACGResponse {
status: string;
data: {
dataSet: JiMengACGItem[];
};
}
async function searchJiMengACG(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "绮梦ACG",
count: 0,
items: [],
};
try {
const url = new URL(API_URL);
url.searchParams.set("filterType", "search");
url.searchParams.set("filterSlug", game);
url.searchParams.set("page", "1");
url.searchParams.set("pageSize", "999999"); // Corresponds to MAX_RESULTS
const response = await fetchClient(url);
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const data = await response.json() as JiMengACGResponse;
if (data.status !== "success") {
throw new Error(`API returned status: ${data.status}`);
}
const items: SearchResultItem[] = data.data.dataSet.map(item => ({
name: item.title.trim(),
url: item.permalink,
}));
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const JiMengACG: Platform = {
name: "绮梦ACG",
color: "lime",
magic: false,
search: searchJiMengACG,
};
export default JiMengACG;

View File

@@ -0,0 +1,63 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://koyso.to/";
const BASE_URL = "https://koyso.to";
const REGEX = /<a class="game_item" href="(?<URL>.+?)"\s*>.*?<span style="background-color: rgba\(128,128,128,0\)">(?<NAME>.+?)<\/span>/gs;
async function searchKoyso(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "Koyso",
count: 0,
items: [],
};
try {
const url = new URL(API_URL);
url.searchParams.set("keywords", game);
const response = await fetchClient(url);
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const html = await response.text();
// --- DEBUGGING: Print the full HTML content ---
// console.log("Koyso API HTML Response:", html.substring(0, 1000)); // Print first 1000 chars
// --- END DEBUGGING ---
const matches = html.matchAll(REGEX);
const items: SearchResultItem[] = [];
for (const match of matches) {
if (match.groups?.NAME && match.groups?.URL) {
items.push({
name: match.groups.NAME.trim(),
url: BASE_URL + encodeURIComponent(match.groups.URL),
});
}
}
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const Koyso: Platform = {
name: "Koyso",
color: "lime",
magic: false,
search: searchKoyso,
};
export default Koyso;

View File

@@ -0,0 +1,67 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://www.kungal.com/api/search";
const BASE_URL = "https://www.kungal.com/zh-cn/galgame/";
interface KunGalgameItem {
id: number;
name: {
"zh-cn": string;
"ja-jp": string;
};
}
async function searchKunGalgame(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "鲲Galgame",
count: 0,
items: [],
};
try {
const url = new URL(API_URL);
url.searchParams.set("keywords", game);
url.searchParams.set("type", "galgame");
url.searchParams.set("page", "1");
url.searchParams.set("limit", "12"); // Hardcoded as per original script
const response = await fetchClient(url);
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const data = await response.json() as KunGalgameItem[];
const items: SearchResultItem[] = data.map(item => {
const zhName = item.name["zh-cn"]?.trim();
const jpName = item.name["ja-jp"]?.trim();
return {
name: zhName || jpName,
url: BASE_URL + item.id,
};
});
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const KunGalgame: Platform = {
name: "鲲Galgame",
color: "lime",
magic: false,
search: searchKunGalgame,
};
export default KunGalgame;

View File

@@ -0,0 +1,59 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const DATA_URL = "https://www.limulu.moe/search.xml";
const BASE_URL = "https://www.limulu.moe";
const REGEX = /<entry>.*?<title>(.*?)<\/title>.*?<url>(.*?)<\/url>.*?<\/entry>/gs;
async function searchLiSiTanACG(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "莉斯坦ACG",
count: 0,
items: [],
};
try {
const response = await fetchClient(DATA_URL);
if (!response.ok) {
throw new Error(`Failed to fetch data from ${DATA_URL}`);
}
const xmlText = await response.text();
const matches = xmlText.matchAll(REGEX);
const items: SearchResultItem[] = [];
for (const match of matches) {
const title = match[1];
const urlPath = match[2];
if (title && urlPath && title.includes(game)) {
items.push({
name: title.trim(),
url: BASE_URL + urlPath,
});
}
}
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const LiSiTanACG: Platform = {
name: "莉斯坦ACG",
color: "lime",
magic: false,
search: searchLiSiTanACG,
};
export default LiSiTanACG;

View File

@@ -0,0 +1,58 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://lzacg.org/";
const REGEX = />\s*<h2 class="item-heading"><a target="_blank" href="(?<URL>.*?)">(?<NAME>.*?)<\/a><\/h2>\s*<div/gs;
async function searchLiangZiACG(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "量子acg",
count: 0,
items: [],
};
try {
const url = new URL(API_URL);
url.searchParams.set("s", game);
const response = await fetchClient(url);
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const html = await response.text();
const matches = html.matchAll(REGEX);
const items: SearchResultItem[] = [];
for (const match of matches) {
if (match.groups?.NAME && match.groups?.URL) {
items.push({
name: match.groups.NAME.trim(),
url: match.groups.URL,
});
}
}
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const LiangZiACG: Platform = {
name: "量子acg",
color: "white",
magic: false,
search: searchLiangZiACG,
};
export default LiangZiACG;

View File

@@ -0,0 +1,84 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://catcat.cloud/api/fs/search";
const BASE_URL = "https://catcat.cloud";
interface MaoMaoItem {
name: string;
parent: string;
}
interface MaoMaoResponse {
message: string;
data: {
content: MaoMaoItem[];
total: number;
};
}
async function searchMaoMaoWangPan(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "猫猫网盘",
count: 0,
items: [],
};
try {
const payload = {
parent: "/GalGame/",
keywords: game,
scope: 0,
page: 1,
per_page: 999999, // Corresponds to MAX_RESULTS
password: "",
};
const response = await fetchClient(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const data = await response.json() as MaoMaoResponse;
if (data.message !== "success") {
throw new Error(`API returned an error: ${data.message}`);
}
const items: SearchResultItem[] = data.data.content
.filter(item => item.parent.startsWith("/GalGame/SP后端1[GalGame分区]/"))
.map(item => ({
name: item.name.trim(),
url: BASE_URL + item.parent + "/" + item.name,
}));
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const MaoMaoWangPan: Platform = {
name: "猫猫网盘",
color: "lime",
magic: false,
search: searchMaoMaoWangPan,
};
export default MaoMaoWangPan;

View File

@@ -0,0 +1,63 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://www.nyantaku.com/";
const REGEX = /<div class="item-thumbnail">\s*<a target="_blank" href="(?<URL>.*?)">.+?" alt="(?<NAME>.*?)" class="lazyload/gs;
async function searchMiaoYuanLingYu(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "喵源领域",
count: 0,
items: [],
};
try {
const url = new URL(API_URL);
url.searchParams.set("type", "post");
url.searchParams.set("s", game);
const response = await fetchClient(url);
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const html = await response.text();
const matches = html.matchAll(REGEX);
const items: SearchResultItem[] = [];
for (const match of matches) {
if (match.groups?.NAME && match.groups?.URL) {
let name = match.groups.NAME.trim();
if (name.endsWith("-喵源领域")) {
name = name.substring(0, name.length - "-喵源领域".length).trim();
}
items.push({
name: name,
url: match.groups.URL,
});
}
}
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const MiaoYuanLingYu: Platform = {
name: "喵源领域",
color: "white",
magic: false,
search: searchMiaoYuanLingYu,
};
export default MiaoYuanLingYu;

View File

@@ -0,0 +1,67 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://res.nyne.dev/api/resource/search";
const BASE_URL = "https://res.nyne.dev/resources/";
interface NysoureItem {
id: number;
title: string;
}
interface NysoureResponse {
success: boolean;
data: NysoureItem[];
}
async function searchNysoure(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "Nysoure",
count: 0,
items: [],
};
try {
const url = new URL(API_URL);
url.searchParams.set("keyword", game);
url.searchParams.set("page", "1");
const response = await fetchClient(url);
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const data = await response.json() as NysoureResponse;
if (!data.success) {
throw new Error("API returned success: false");
}
const items: SearchResultItem[] = data.data.map(item => ({
name: item.title.trim(),
url: BASE_URL + item.id,
}));
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const Nysoure: Platform = {
name: "Nysoure",
color: "gold",
magic: true,
search: searchNysoure,
};
export default Nysoure;

View File

@@ -0,0 +1,64 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://www.qingju.org/";
const REGEX = /" class="lazyload fit-cover radius8">.*?<h2 class="item-heading"><a href="(?<URL>.*?)">(?<NAME>.*?)<\/a><\/h2>/gs;
async function searchQingJiACG(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "青桔ACG",
count: 0,
items: [],
};
try {
const url = new URL(API_URL);
url.searchParams.set("s", game);
url.searchParams.set("type", "post");
const response = await fetchClient(url);
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const html = await response.text();
const matches = html.matchAll(REGEX);
const items: SearchResultItem[] = [];
for (const match of matches) {
if (match.groups?.NAME && match.groups?.URL) {
// Original Python script had a filter: if "</p>" in i.group("URL"): continue
// This is likely to filter out malformed URLs or irrelevant matches.
if (match.groups.URL.includes("</p>")) {
continue;
}
items.push({
name: match.groups.NAME.trim(),
url: match.groups.URL,
});
}
}
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const QingJiACG: Platform = {
name: "青桔ACG",
color: "lime",
magic: false,
search: searchQingJiACG,
};
export default QingJiACG;

View File

@@ -0,0 +1,58 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://www.chgal.com/";
const REGEX = /<h2 class="post-list-title">\s*<a href="(?<URL>.*?)" title=".+?" class="text-reset">(?<NAME>.*?)<\/a>\s*<\/h2>\s*<span class="category-meta">/gs;
async function searchShenShiTianTang(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "绅仕天堂",
count: 0,
items: [],
};
try {
const url = new URL(API_URL);
url.searchParams.set("s", game);
const response = await fetchClient(url);
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const html = await response.text();
const matches = html.matchAll(REGEX);
const items: SearchResultItem[] = [];
for (const match of matches) {
if (match.groups?.NAME && match.groups?.URL) {
items.push({
name: match.groups.NAME.trim(),
url: match.groups.URL,
});
}
}
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const ShenShiTianTang: Platform = {
name: "绅仕天堂",
color: "gold",
magic: true,
search: searchShenShiTianTang,
};
export default ShenShiTianTang;

View File

@@ -0,0 +1,55 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const DATA_URL = "https://peach.sslswwdx.top/page/search/index.json";
interface TaoHuaYuanItem {
title: string;
permalink: string;
}
async function searchTaoHuaYuan(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "桃花源",
count: 0,
items: [],
};
try {
const response = await fetchClient(DATA_URL);
if (!response.ok) {
throw new Error(`Failed to fetch data from ${DATA_URL}`);
}
const data = await response.json() as TaoHuaYuanItem[];
const items: SearchResultItem[] = data
.filter(item => item.title.includes(game))
.map(item => ({
name: item.title.trim(),
url: item.permalink,
}));
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const TaoHuaYuan: Platform = {
name: "桃花源",
color: "lime",
magic: false,
search: searchTaoHuaYuan,
};
export default TaoHuaYuan;

View File

@@ -0,0 +1,57 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://www.tiangal.com/search/";
const REGEX = /<\/i><\/a><h2><a href="(?<URL>.*?)" title="(?<NAME>.*?)"/gs;
async function searchTianYouErCiYuan(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "天游二次元",
count: 0,
items: [],
};
try {
const url = new URL(API_URL + encodeURIComponent(game)); // URL path parameter
const response = await fetchClient(url);
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const html = await response.text();
const matches = html.matchAll(REGEX);
const items: SearchResultItem[] = [];
for (const match of matches) {
if (match.groups?.NAME && match.groups?.URL) {
items.push({
name: match.groups.NAME.trim(),
url: match.groups.URL,
});
}
}
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const TianYouErCiYuan: Platform = {
name: "天游二次元",
color: "gold",
magic: true,
search: searchTianYouErCiYuan,
};
export default TianYouErCiYuan;

View File

@@ -0,0 +1,74 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://www.touchgal.us/api/search";
const BASE_URL = "https://www.touchgal.us/";
async function searchTouchGal(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "TouchGal",
count: 0,
items: [],
};
try {
const payload = {
queryString: JSON.stringify([{ type: "keyword", name: game }]),
limit: 24, // Hardcoded as per original script
searchOption: {
searchInIntroduction: false,
searchInAlias: true,
searchInTag: false,
},
page: 1,
selectedType: "all",
selectedLanguage: "all",
selectedPlatform: "all",
sortField: "resource_update_time",
sortOrder: "desc",
selectedYears: ["all"],
selectedMonths: ["all"],
};
const response = await fetchClient(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const data = await response.json() as { galgames: { name: string; uniqueId: string }[] };
const items: SearchResultItem[] = data.galgames.map(item => ({
name: item.name.trim(),
url: BASE_URL + item.uniqueId,
}));
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const TouchGal: Platform = {
name: "TouchGal",
color: "lime",
magic: false,
search: searchTouchGal,
};
export default TouchGal;

View File

@@ -0,0 +1,103 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://www.vikacg.com/wp-json/b2/v1/getPostList";
// The Python code suggests the response text itself might be a JSON string
// that contains escaped HTML. Let's try to parse it as JSON first.
// The regex is applied to the *unescaped* string.
const REGEX = /<h2><a target="_blank" href="(?<URL>.*?)" title="(?<NAME>.*?)"/gs;
async function searchVikaACG(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "VikaACG",
count: 0,
items: [],
};
try {
const payload = {
paged: 1,
post_paged: 1,
post_count: 1000, // Corresponds to MAX_RESULTS
post_type: "post-1",
post_cat: [6],
post_order: "modified",
post_meta: [
"user", "date", "des", "cats", "like", "comment", "views", "video", "download", "hide",
],
metas: {},
search: game,
};
const response = await fetchClient(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const rawText = await response.text();
let htmlContent: string;
try {
// Attempt to parse as JSON first. If it's a JSON string containing HTML,
// JSON.parse will handle standard escapes like \uXXXX.
const parsedJson = JSON.parse(rawText);
// Assuming the HTML content is directly the value of the JSON, or a specific field.
// The Python code implies the entire response text, after unescaping, is the HTML.
// So, if parsedJson is a string, use it. Otherwise, stringify it.
htmlContent = typeof parsedJson === 'string' ? parsedJson : JSON.stringify(parsedJson);
} catch (jsonError) {
// If JSON.parse fails, it might be due to non-standard Python escapes like \\/ or \\\\
// Attempt a simple unescape for these specific cases.
// Note: This is a simplified unescape and might not cover all Python's unicode_escape nuances.
const unescapedText = rawText.replace(/\\(.)/g, '$1'); // Replaces \\/ with / and \\\\ with \
try {
htmlContent = JSON.parse(unescapedText); // Try parsing as JSON again
} catch (finalError) {
// If still fails, assume it's raw HTML that just needs basic unescaping
htmlContent = unescapedText;
}
}
const matches = htmlContent.matchAll(REGEX);
const items: SearchResultItem[] = [];
for (const match of matches) {
if (match.groups?.NAME && match.groups?.URL) {
items.push({
name: match.groups.NAME.trim(),
url: match.groups.URL,
});
}
}
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const VikaACG: Platform = {
name: "VikaACG",
color: "gold",
magic: true,
search: searchVikaACG,
};
export default VikaACG;

View File

@@ -0,0 +1,82 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://www.nullcloud.top/api/fs/search";
const BASE_URL = "https://www.nullcloud.top";
interface WeiZhiYunPanItem {
name: string;
parent: string;
}
interface WeiZhiYunPanResponse {
message: string;
data: {
content: WeiZhiYunPanItem[];
total: number;
};
}
async function searchWeiZhiYunPan(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "未知云盘",
count: 0,
items: [],
};
try {
const payload = {
parent: "/",
keywords: game,
scope: 0,
page: 1,
per_page: 999999, // Corresponds to MAX_RESULTS
password: "",
};
const response = await fetchClient(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const data = await response.json() as WeiZhiYunPanResponse;
if (data.message !== "success") {
throw new Error(`API returned an error: ${data.message}`);
}
const items: SearchResultItem[] = data.data.content.map(item => ({
name: item.name.trim(),
url: BASE_URL + item.parent + "/" + item.name,
}));
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const WeiZhiYunPan: Platform = {
name: "未知云盘",
color: "lime",
magic: false,
search: searchWeiZhiYunPan,
};
export default WeiZhiYunPan;

View File

@@ -0,0 +1,59 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const DATA_URL = "https://yinghu.netlify.app/search.xml";
const BASE_URL = "https://yinghu.netlify.app";
const REGEX = /<entry>.*?<title>(.*?)<\/title>.*?<url>(.*?)<\/url>.*?<\/entry>/gs;
async function searchYingZhiGuang(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "萤ノ光",
count: 0,
items: [],
};
try {
const response = await fetchClient(DATA_URL);
if (!response.ok) {
throw new Error(`Failed to fetch data from ${DATA_URL}`);
}
const xmlText = await response.text();
const matches = xmlText.matchAll(REGEX);
const items: SearchResultItem[] = [];
for (const match of matches) {
const title = match[1];
const urlPath = match[2];
if (title && urlPath && title.includes(game)) {
items.push({
name: title.trim(),
url: BASE_URL + urlPath,
});
}
}
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const YingZhiGuang: Platform = {
name: "萤ノ光",
color: "lime",
magic: false,
search: searchYingZhiGuang,
};
export default YingZhiGuang;

View File

@@ -0,0 +1,61 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://www.ttloli.com/";
const REGEX = /<p style="text-align: center;"> <a href=".*?" target="_blank">.*?<p style="text-align: center;"> <a href="(?<URL>.*?)" title="(?<NAME>.*?)"> <img src=/gs;
async function searchYouYuDeloli(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "忧郁的loli",
count: 0,
items: [],
};
try {
const url = new URL(API_URL);
url.searchParams.set("s", game);
const response = await fetchClient(url);
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const html = await response.text();
const matches = html.matchAll(REGEX);
const items: SearchResultItem[] = [];
for (const match of matches) {
if (match.groups?.NAME && match.groups?.URL) {
if (match.groups.NAME === "详细更新日志") {
continue;
}
items.push({
name: match.groups.NAME.trim(),
url: match.groups.URL,
});
}
}
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const YouYuDeloli: Platform = {
name: "忧郁的loli",
color: "lime",
magic: false,
search: searchYouYuDeloli,
};
export default YouYuDeloli;

View File

@@ -0,0 +1,59 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const DATA_URL = "https://www.sayafx.vip/search.xml";
const BASE_URL = "https://www.sayafx.vip";
const REGEX = /<entry>.*?<title>(.*?)<\/title>.*?<url>(.*?)<\/url>.*?<\/entry>/gs;
async function searchYueYao(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "月谣",
count: 0,
items: [],
};
try {
const response = await fetchClient(DATA_URL);
if (!response.ok) {
throw new Error(`Failed to fetch data from ${DATA_URL}`);
}
const xmlText = await response.text();
const matches = xmlText.matchAll(REGEX);
const items: SearchResultItem[] = [];
for (const match of matches) {
const title = match[1];
const urlPath = match[2];
if (title && urlPath && title.includes(game)) {
items.push({
name: title.trim(),
url: BASE_URL + urlPath,
});
}
}
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const YueYao: Platform = {
name: "月谣",
color: "lime",
magic: false,
search: searchYueYao,
};
export default YueYao;

View File

@@ -0,0 +1,82 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://05fx.022016.xyz/api/fs/search";
const BASE_URL = "https://05fx.022016.xyz";
interface ZeroFiveItem {
name: string;
parent: string;
}
interface ZeroFiveResponse {
message: string;
data: {
content: ZeroFiveItem[];
total: number;
};
}
async function searchZeroFive(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "05的资源小站",
count: 0,
items: [],
};
try {
const payload = {
parent: "/",
keywords: game,
scope: 0,
page: 1,
per_page: 999999, // Corresponds to MAX_RESULTS
password: "",
};
const response = await fetchClient(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const data = await response.json() as ZeroFiveResponse;
if (data.message !== "success") {
throw new Error(`API returned an error: ${data.message}`);
}
const items: SearchResultItem[] = data.data.content.map(item => ({
name: item.name.trim(),
url: BASE_URL + item.parent + "/" + item.name,
}));
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const ZeroFive: Platform = {
name: "05的资源小站",
color: "lime",
magic: false,
search: searchZeroFive,
};
export default ZeroFive;

View File

@@ -0,0 +1,59 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://www.shinnku.com/search";
const BASE_URL = "https://www.shinnku.com";
const REGEX = /hover:underline" href="(?<URL>.+?)">\s*(?<NAME>.+?)\s*<\/a>/gs;
async function searchZhenHongXiaoZhan(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "真红小站",
count: 0,
items: [],
};
try {
const url = new URL(API_URL);
url.searchParams.set("q", game);
const response = await fetchClient(url);
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const html = await response.text();
const matches = html.matchAll(REGEX);
const items: SearchResultItem[] = [];
for (const match of matches) {
if (match.groups?.NAME && match.groups?.URL) {
items.push({
name: match.groups.NAME.trim(),
url: BASE_URL + encodeURIComponent(match.groups.URL),
});
}
}
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const ZhenHongXiaoZhan: Platform = {
name: "真红小站",
color: "lime",
magic: false,
search: searchZhenHongXiaoZhan,
};
export default ZhenHongXiaoZhan;

View File

@@ -0,0 +1,82 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://zi0.cc/api/fs/search";
const BASE_URL = "https://zi0.cc";
interface ZiLingDeMiaoMiaoWuItem {
name: string;
parent: string;
}
interface ZiLingDeMiaoMiaoWuResponse {
message: string;
data: {
content: ZiLingDeMiaoMiaoWuItem[];
total: number;
};
}
async function searchZiLingDeMiaoMiaoWu(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "梓澪の妙妙屋",
count: 0,
items: [],
};
try {
const payload = {
parent: "/",
keywords: game,
scope: 0,
page: 1,
per_page: 999999, // Corresponds to MAX_RESULTS
password: "",
};
const response = await fetchClient(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const data = await response.json() as ZiLingDeMiaoMiaoWuResponse;
if (data.message !== "success") {
throw new Error(`API returned an error: ${data.message}`);
}
const items: SearchResultItem[] = data.data.content.map(item => ({
name: item.name.trim(),
url: BASE_URL + item.parent + "/" + item.name,
}));
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const ZiLingDeMiaoMiaoWu: Platform = {
name: "梓澪の妙妙屋",
color: "lime",
magic: false,
search: searchZiLingDeMiaoMiaoWu,
};
export default ZiLingDeMiaoMiaoWu;

View File

@@ -0,0 +1,82 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://galzy.eu.org/api/fs/search";
const BASE_URL = "https://galzy.eu.org";
interface ZiYuanSheItem {
name: string;
parent: string;
}
interface ZiYuanSheResponse {
message: string;
data: {
content: ZiYuanSheItem[];
total: number;
};
}
async function searchZiYuanShe(game: string, zypassword: string = ""): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "紫缘Gal",
count: 0,
items: [],
};
try {
const payload = {
parent: "/",
keywords: game,
scope: 0,
page: 1,
per_page: 999999, // Corresponds to MAX_RESULTS
password: zypassword, // Pass the zypassword here
};
const response = await fetchClient(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const data = await response.json() as ZiYuanSheResponse;
if (data.message !== "success") {
throw new Error(`API returned an error: ${data.message}`);
}
const items: SearchResultItem[] = data.data.content.map(item => ({
name: item.name.trim(),
url: BASE_URL + item.parent + "/" + item.name,
}));
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const ZiYuanShe: Platform = {
name: "紫缘Gal",
color: "white",
magic: false,
search: searchZiYuanShe,
};
export default ZiYuanShe;

View File

@@ -0,0 +1,70 @@
import type { Platform } from "../../types";
import ACGYingYingGuai from "./ACGYingYingGuai";
import BiAnXingLu from "./BiAnXingLu";
import DaoHeGal from "./DaoHeGal";
import FuFuACG from "./FuFuACG";
import GalgameX from "./GalgameX";
import GalTuShuGuan from "./GalTuShuGuan";
import GGBases from "./GGBases";
import GGS from "./GGS";
import Hikarinagi from "./Hikarinagi";
import JiMengACG from "./JiMengACG";
import Koyso from "./Koyso";
import KunGalgame from "./KunGalgame";
import LiangZiACG from "./LiangZiACG";
import LiSiTanACG from "./LiSiTanACG";
import MaoMaoWangPan from "./MaoMaoWangPan";
import MiaoYuanLingYu from "./MiaoYuanLingYu";
import Nysoure from "./Nysoure";
import QingJiACG from "./QingJiACG";
import ShenShiTianTang from "./ShenShiTianTang";
import TaoHuaYuan from "./TaoHuaYuan";
import TianYouErCiYuan from "./TianYouErCiYuan";
import TouchGal from "./TouchGal";
import VikaACG from "./VikaACG";
import WeiZhiYunPan from "./WeiZhiYunPan";
import xxacg from "./xxacg";
import YingZhiGuang from "./YingZhiGuang";
import YouYuDeloli from "./YouYuDeloli";
import YueYao from "./YueYao";
import ZeroFive from "./ZeroFive";
import ZhenHongXiaoZhan from "./ZhenHongXiaoZhan";
import ZiLingDeMiaoMiaoWu from "./ZiLingDeMiaoMiaoWu";
import ZiYuanShe from "./ZiYuanShe";
const platforms: Platform[] = [
ACGYingYingGuai,
BiAnXingLu,
DaoHeGal,
FuFuACG,
GalgameX,
GalTuShuGuan,
GGBases,
GGS,
Hikarinagi,
JiMengACG,
Koyso,
KunGalgame,
LiangZiACG,
LiSiTanACG,
MaoMaoWangPan,
MiaoYuanLingYu,
Nysoure,
QingJiACG,
ShenShiTianTang,
TaoHuaYuan,
TianYouErCiYuan,
TouchGal,
VikaACG,
WeiZhiYunPan,
xxacg,
YingZhiGuang,
YouYuDeloli,
YueYao,
ZeroFive,
ZhenHongXiaoZhan,
ZiLingDeMiaoMiaoWu,
ZiYuanShe,
];
export default platforms;

View File

@@ -0,0 +1,69 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const REGEX = /<h4 class="entry-title title"><a href="(?<URL>.*?)">(?<NAME>.*?)<\/a><\/h4>/gs;
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, "").trim();
}
async function searchXxacg(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "xxacg",
count: 0,
items: [],
};
let html = ""; // 将 html 变量提升到 try 块外部
try {
const url = new URL("https://xxacg.net/");
url.searchParams.set("s", game);
const response = await fetchClient(url);
if (!response.ok) {
throw new Error(`Search API response status code is ${response.status}`);
}
html = await response.text();
const matches = html.matchAll(REGEX);
const items: SearchResultItem[] = [];
for (const match of matches) {
if (match.groups?.NAME && match.groups?.URL) {
items.push({
name: stripHtml(match.groups.NAME),
url: match.groups.URL,
});
}
}
if (items.length === 0 && html.length > 0) {
// 如果没有匹配项,但我们确实收到了 HTML这可能意味着页面结构已更改。
// 将部分 HTML 包含在错误中以供调试。
throw new Error(`No matches found on page. HTML starts with: ${html.substring(0, 500)}`);
}
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const xxacg: Platform = {
name: "xxacg",
color: "gold",
magic: true,
search: searchXxacg,
};
export default xxacg;

View File

@@ -0,0 +1,76 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://www.moyu.moe/api/search";
const BASE_URL = "https://www.moyu.moe/patch/";
interface KunGalgameBuDingItem {
id: number;
name: string;
}
interface KunGalgameBuDingResponse {
galgames: KunGalgameBuDingItem[];
}
async function searchKunGalgameBuDing(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "鲲Galgame补丁",
count: 0,
items: [],
};
try {
const payload = {
limit: 24, // Hardcoded as per original script
page: 1,
query: game.split(/\s+/), // Split by whitespace
searchOption: {
searchInAlias: true,
searchInIntroduction: false,
searchInTag: false,
},
};
const response = await fetchClient(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const data = await response.json() as KunGalgameBuDingResponse;
const items: SearchResultItem[] = data.galgames.map(item => ({
name: item.name,
url: `${BASE_URL}${item.id}/introduction`,
}));
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const KunGalgameBuDing: Platform = {
name: "鲲Galgame补丁",
color: "lime",
magic: false,
search: searchKunGalgameBuDing,
};
export default KunGalgameBuDing;

View File

@@ -0,0 +1,65 @@
import { fetchClient } from "../../utils/httpClient";
import type { Platform, PlatformSearchResult, SearchResultItem } from "../../types";
const API_URL = "https://2dfan.com/subjects/search";
const BASE_URL = "https://2dfan.com";
const REGEX = /<h4 class="media-heading"><a target="_blank" href="(?<URL>.*?)">(?<NAME>.*?)<\/a><\/h4>/gs;
interface TwoDFanResponse {
subjects: string; // This is an HTML string
}
async function searchTWOdfan(game: string): Promise<PlatformSearchResult> {
const searchResult: PlatformSearchResult = {
name: "2dfan",
count: 0,
items: [],
};
try {
const url = new URL(API_URL);
url.searchParams.set("keyword", game);
const response = await fetchClient(url);
if (!response.ok) {
throw new Error(`API response status code is ${response.status}`);
}
const data = await response.json() as TwoDFanResponse;
const html = data.subjects;
const matches = html.matchAll(REGEX);
const items: SearchResultItem[] = [];
for (const match of matches) {
if (match.groups?.NAME && match.groups?.URL) {
items.push({
name: match.groups.NAME.trim(),
url: BASE_URL + match.groups.URL,
});
}
}
searchResult.items = items;
searchResult.count = items.length;
} catch (error) {
if (error instanceof Error) {
searchResult.error = error.message;
} else {
searchResult.error = "An unknown error occurred";
}
searchResult.count = -1;
}
return searchResult;
}
const TWOdfan: Platform = {
name: "2dfan",
color: "lime",
magic: false,
search: searchTWOdfan,
};
export default TWOdfan;

View File

@@ -0,0 +1,10 @@
import type { Platform } from "../../types";
import KunGalgameBuDing from "./KunGalgameBuDing";
import TWOdfan from "./TWOdfan";
const platforms: Platform[] = [
KunGalgameBuDing,
TWOdfan,
];
export default platforms;

36
src/ratelimit.ts Normal file
View File

@@ -0,0 +1,36 @@
// --- 速率限制常量 ---
const SEARCH_INTERVAL_SECONDS = 15;
// KV 条目将在此秒数后自动过期,以防止存储膨胀
const IP_ENTRY_TTL_SECONDS = 60;
/**
* 检查给定 IP 地址是否超出了速率限制。
* @param ip 客户端的 IP 地址。
* @param kvNamespace 用于存储 IP 时间戳的 KV 命名空间。
* @returns 返回一个对象,包含是否允许请求以及剩余的等待秒数。
*/
export async function checkRateLimit(
ip: string,
kvNamespace: KVNamespace
): Promise<{ allowed: boolean; retryAfter: number }> {
const currentTime = Math.floor(Date.now() / 1000);
const lastSearchTimeStr = await kvNamespace.get(ip);
const lastSearchTime = lastSearchTimeStr ? parseInt(lastSearchTimeStr, 10) : 0;
if (lastSearchTime && (currentTime - lastSearchTime) < SEARCH_INTERVAL_SECONDS) {
return {
allowed: false,
retryAfter: SEARCH_INTERVAL_SECONDS - (currentTime - lastSearchTime),
};
}
// 更新 IP 的最后搜索时间,并设置 TTL
await kvNamespace.put(ip, currentTime.toString(), {
expirationTtl: IP_ENTRY_TTL_SECONDS,
});
return {
allowed: true,
retryAfter: 0,
};
}

34
src/types.ts Normal file
View File

@@ -0,0 +1,34 @@
// 单个搜索结果
export interface SearchResultItem {
name: string;
url: string;
}
// 平台搜索的返回值
export interface PlatformSearchResult {
items: SearchResultItem[];
count: number;
name: string;
error?: string;
}
// 平台对象的接口
export interface Platform {
name: string;
color: string;
magic: boolean;
search: (game: string, ...args: any[]) => Promise<PlatformSearchResult>;
}
// SSE 事件流中的数据结构
export interface StreamResult {
name: string;
color: string;
items: SearchResultItem[];
error?: string;
}
export interface StreamProgress {
completed: number;
total: number;
}

41
src/utils/httpClient.ts Normal file
View File

@@ -0,0 +1,41 @@
const TIMEOUT_SECONDS = 15;
const HEADERS = {
"Connection": "close",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 (From SearchGal.homes) (https://github.com/Moe-Sakura/SearchGal)",
};
/**
* 一个封装了原生 fetch 并增加了超时功能的 HTTP 客户端。
* @param url 请求的 URL。
* @param options fetch 的请求选项。
* @returns 返回一个 Promise<Response>。
*/
export async function fetchClient(
url: string | URL,
options: RequestInit = {}
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_SECONDS * 1000);
const finalOptions: RequestInit = {
...options,
headers: {
...HEADERS,
...options.headers,
},
signal: controller.signal,
};
try {
const response = await fetch(url, finalOptions);
return response;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Request timed out after ${TIMEOUT_SECONDS} seconds`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"lib": ["esnext"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["@cloudflare/workers-types"]
},
"include": ["src"]
}

11
wrangler.toml Normal file
View File

@@ -0,0 +1,11 @@
name = "searchgal-worker"
main = "src/index.ts"
compatibility_date = "2023-10-30"
[vars]
# 如果有需要,可以在这里添加环境变量
# --- KV 命名空间绑定 ---
[[kv_namespaces]]
binding = "RATE_LIMIT_KV"
id = "7ed4c4f36baf419bb9ed54538a61f473"