Spaces:
Running
Running
const express = require('express'); | |
const axios = require('axios'); | |
const cheerio = require('cheerio'); | |
const cors = require('cors'); | |
const app = express(); | |
const PORT = 7860; | |
const HOST = '0.0.0.0'; | |
app.use(cors()); | |
app.use(express.json()); | |
function extractPixivId(url) { | |
const patterns = [ | |
/pixiv\.net\/(?:en\/)?artworks\/(\d+)/, | |
/pixiv\.net\/member_illust\.php\?.*illust_id=(\d+)/, | |
/pixiv\.net\/(?:en\/)?users\/\d+\/artworks\/(\d+)/ | |
]; | |
for (let pattern of patterns) { | |
const match = url.match(pattern); | |
if (match) return match[1]; | |
} | |
return null; | |
} | |
async function getPixivMetadata(artworkId, req) { | |
try { | |
const apiUrl = `https://www.pixiv.net/ajax/illust/${artworkId}`; | |
const pageUrl = `https://www.pixiv.net/en/artworks/${artworkId}`; | |
// Set headers to mimic a browser request | |
const headers = { | |
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', | |
'Accept': 'application/json', | |
'Accept-Language': 'en-US,en;q=0.9', | |
'Referer': 'https://www.pixiv.net/' | |
}; | |
try { | |
const response = await axios.get(apiUrl, { headers }); | |
if (response.data && response.data.body) { | |
const data = response.data.body; | |
const baseUrl = req.protocol + '://' + req.get('host'); | |
return { | |
id: data.id, | |
title: data.title, | |
description: data.description, | |
artist: { | |
id: data.userId, | |
name: data.userName, | |
account: data.userAccount | |
}, | |
tags: data.tags ? data.tags.tags.map(tag => ({ | |
tag: tag.tag, | |
translation: tag.translation ? tag.translation.en : null | |
})) : [], | |
images: { | |
thumbnail: data.urls ? data.urls.thumb : null, | |
small: data.urls ? data.urls.small : null, | |
regular: data.urls ? data.urls.regular : null, | |
original: data.urls ? data.urls.original : null | |
}, | |
proxiedImages: { | |
thumbnail: data.urls?.thumb ? `${baseUrl}/api/image-proxy?url=${encodeURIComponent(data.urls.thumb)}` : null, | |
small: data.urls?.small ? `${baseUrl}/api/image-proxy?url=${encodeURIComponent(data.urls.small)}` : null, | |
regular: data.urls?.regular ? `${baseUrl}/api/image-proxy?url=${encodeURIComponent(data.urls.regular)}` : null, | |
original: data.urls?.original ? `${baseUrl}/api/image-proxy?url=${encodeURIComponent(data.urls.original)}` : null | |
}, | |
stats: { | |
views: data.viewCount, | |
bookmarks: data.bookmarkCount, | |
likes: data.likeCount, | |
comments: data.commentCount | |
}, | |
pageCount: data.pageCount, | |
width: data.width, | |
height: data.height, | |
createDate: data.createDate, | |
uploadDate: data.uploadDate, | |
type: data.illustType === 0 ? 'illustration' : data.illustType === 1 ? 'manga' : 'ugoira', | |
isR18: data.xRestrict === 1, | |
isAI: data.aiType === 2 | |
}; | |
} | |
} catch (apiError) { | |
console.log('API endpoint failed, trying page scraping...'); | |
} | |
const pageResponse = await axios.get(pageUrl, { headers }); | |
const $ = cheerio.load(pageResponse.data); | |
let metadata = null; | |
$('script').each((i, elem) => { | |
const text = $(elem).html(); | |
if (text && text.includes('meta-preload-data')) { | |
try { | |
const match = text.match(/{"timestamp".*?}(?=<\/script>)/); | |
if (match) { | |
const data = JSON.parse(match[0]); | |
if (data.illust && data.illust[artworkId]) { | |
const illust = data.illust[artworkId]; | |
const baseUrl = req.protocol + '://' + req.get('host'); | |
metadata = { | |
id: illust.id, | |
title: illust.title, | |
description: illust.description, | |
artist: { | |
id: illust.userId, | |
name: illust.userName, | |
account: illust.userAccount || null | |
}, | |
tags: illust.tags ? illust.tags.tags.map(tag => ({ | |
tag: tag.tag, | |
translation: tag.translation ? tag.translation.en : null | |
})) : [], | |
images: { | |
thumbnail: illust.urls ? illust.urls.thumb : null, | |
small: illust.urls ? illust.urls.small : null, | |
regular: illust.urls ? illust.urls.regular : null, | |
original: illust.urls ? illust.urls.original : null | |
}, | |
proxiedImages: { | |
thumbnail: illust.urls?.thumb ? `${baseUrl}/api/image-proxy?url=${encodeURIComponent(illust.urls.thumb)}` : null, | |
small: illust.urls?.small ? `${baseUrl}/api/image-proxy?url=${encodeURIComponent(illust.urls.small)}` : null, | |
regular: illust.urls?.regular ? `${baseUrl}/api/image-proxy?url=${encodeURIComponent(illust.urls.regular)}` : null, | |
original: illust.urls?.original ? `${baseUrl}/api/image-proxy?url=${encodeURIComponent(illust.urls.original)}` : null | |
}, | |
stats: { | |
views: illust.viewCount || 0, | |
bookmarks: illust.bookmarkCount || 0, | |
likes: illust.likeCount || 0, | |
comments: illust.commentCount || 0 | |
}, | |
pageCount: illust.pageCount || 1, | |
width: illust.width, | |
height: illust.height, | |
createDate: illust.createDate, | |
uploadDate: illust.uploadDate, | |
type: illust.illustType === 0 ? 'illustration' : illust.illustType === 1 ? 'manga' : 'ugoira', | |
isR18: illust.xRestrict === 1, | |
isAI: illust.aiType === 2 | |
}; | |
} | |
} | |
} catch (e) { | |
} | |
} | |
}); | |
if (metadata) { | |
return metadata; | |
} | |
const baseUrl = req.protocol + '://' + req.get('host'); | |
const ogImage = $('meta[property="og:image"]').attr('content'); | |
return { | |
id: artworkId, | |
title: $('meta[property="og:title"]').attr('content') || 'Unknown', | |
description: $('meta[property="og:description"]').attr('content') || '', | |
artist: { | |
name: $('meta[name="twitter:creator"]').attr('content') || 'Unknown' | |
}, | |
images: { | |
thumbnail: ogImage || null | |
}, | |
proxiedImages: { | |
thumbnail: ogImage ? `${baseUrl}/api/image-proxy?url=${encodeURIComponent(ogImage)}` : null | |
}, | |
tags: [], | |
stats: {}, | |
pageCount: 1, | |
type: 'illustration' | |
}; | |
} catch (error) { | |
throw new Error(`Failed to fetch metadata: ${error.message}`); | |
} | |
} | |
app.get('/api/image-proxy', async (req, res) => { | |
try { | |
const { url } = req.query; | |
if (!url) { | |
return res.status(400).json({ | |
error: 'Please provide an image URL', | |
example: '/api/image-proxy?url=https://i.pximg.net/...' | |
}); | |
} | |
if (!url.includes('pximg.net')) { | |
return res.status(400).json({ | |
error: 'Only Pixiv image URLs are supported' | |
}); | |
} | |
const headers = { | |
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', | |
'Referer': 'https://www.pixiv.net/', | |
'Accept': 'image/webp,image/apng,image/*,*/*;q=0.8', | |
'Accept-Language': 'en-US,en;q=0.9' | |
}; | |
const response = await axios.get(url, { | |
headers, | |
responseType: 'stream', | |
timeout: 30000 | |
}); | |
const contentType = response.headers['content-type'] || 'image/jpeg'; | |
res.setHeader('Content-Type', contentType); | |
res.setHeader('Cache-Control', 'public, max-age=3600'); // Cache for 1 hour | |
response.data.pipe(res); | |
} catch (error) { | |
if (error.response && error.response.status === 403) { | |
res.status(403).json({ | |
error: 'Access forbidden - image may be restricted or URL invalid' | |
}); | |
} else { | |
res.status(500).json({ | |
error: `Failed to fetch image: ${error.message}` | |
}); | |
} | |
} | |
}); | |
app.get('/api/pixiv', async (req, res) => { | |
try { | |
const { url, id } = req.query; | |
if (!url && !id) { | |
return res.status(400).json({ | |
error: 'Please provide either a Pixiv URL or artwork ID', | |
example: '/api/pixiv?url=https://www.pixiv.net/en/artworks/123456789' | |
}); | |
} | |
let artworkId = id; | |
if (url) { | |
artworkId = extractPixivId(url); | |
if (!artworkId) { | |
return res.status(400).json({ | |
error: 'Invalid Pixiv URL format', | |
supportedFormats: [ | |
'https://www.pixiv.net/en/artworks/{id}', | |
'https://www.pixiv.net/artworks/{id}', | |
'https://www.pixiv.net/member_illust.php?illust_id={id}' | |
] | |
}); | |
} | |
} | |
const metadata = await getPixivMetadata(artworkId, req); | |
res.json({ | |
success: true, | |
data: metadata | |
}); | |
} catch (error) { | |
res.status(500).json({ | |
error: error.message, | |
success: false | |
}); | |
} | |
}); | |
app.get('/health', (req, res) => { | |
res.json({ status: 'OK', timestamp: new Date().toISOString() }); | |
}); | |
app.get('/', (req, res) => { | |
res.json({ | |
name: 'Pixiv Metadata API', | |
version: '1.0.0', | |
endpoints: { | |
'/api/pixiv': { | |
method: 'GET', | |
description: 'Get metadata for a Pixiv artwork', | |
parameters: { | |
url: 'Pixiv artwork URL (optional if id is provided)', | |
id: 'Pixiv artwork ID (optional if url is provided)' | |
}, | |
example: '/api/pixiv?url=https://www.pixiv.net/en/artworks/123456789' | |
}, | |
'/api/image-proxy': { | |
method: 'GET', | |
description: 'Proxy Pixiv images to bypass hotlink protection', | |
parameters: { | |
url: 'Pixiv image URL (pximg.net)' | |
}, | |
example: '/api/image-proxy?url=https://i.pximg.net/...' | |
}, | |
'/health': { | |
method: 'GET', | |
description: 'Health check endpoint' | |
} | |
} | |
}); | |
}); | |
app.listen(PORT, HOST, () => { | |
console.log(`Running on http://${HOST}:${PORT}`); | |
}); | |
module.exports = app; |