├── package.json ├── wrangler.toml ├── LICENSE ├── README.md └── index.js /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pixivcat-cloudflare-workers", 3 | "version": "2.1.0", 4 | "description": "Pixiv.cat on Cloudflare Workers", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [ 10 | "pixiv" 11 | ], 12 | "author": "jongcs", 13 | "license": "MIT" 14 | } 15 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | # https://developers.cloudflare.com/workers/wrangler/configuration/ 2 | name = "pixivcat-cloudflare-workers" 3 | main = "index.js" 4 | compatibility_date = "2024-03-20" 5 | account_id = "" 6 | compatibility_flags = [] 7 | workers_dev = true 8 | 9 | [vars] 10 | PIXIV_API_ENDPOINT = "app-api.pixiv.net" 11 | PIXIV_OAUTH_ENDPOINT = "oauth.secure.pixiv.net" 12 | 13 | [placement] 14 | mode = "off" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Pixiv.Cat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pixivcat-cloudflare-workers 2 | 3 | Pixiv.cat on Cloudflare Workers 4 | 5 | ## Setup 6 | 7 | 1. (Optional)[Install wrangler](https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler). 8 | 9 | 2. [Get your REFRESH_TOKEN](https://gist.github.com/upbit/6edda27cb1644e94183291109b8a5fde). 10 | 11 | 3. Set your `REFRESH_TOKEN` as encrypted environment variable in Cloudflare Workers. 12 | 13 | ```text 14 | wrangler secret put REFRESH_TOKEN 15 | ``` 16 | 17 | Alternatively, you can set environment variables in "Settings" tab of your workers project. 18 | 19 | 4. Update `PIXIV_API_ENDPOINT` and `PIXIV_OAUTH_ENDPOINT` variables in `wrangler.toml`. 20 | You will need to set up two reverse proxy on your own web server, here is an example for nginx: 21 | 22 | ```text 23 | server { 24 | listen 443 ssl; 25 | 26 | ssl_certificate /path/to/certificate.pem; 27 | ssl_certificate_key /path/to/certificate.key; 28 | 29 | server_name oauth.example.com; 30 | 31 | location / { 32 | proxy_pass https://oauth.secure.pixiv.net; 33 | proxy_ssl_server_name on; 34 | proxy_set_header Host oauth.secure.pixiv.net; 35 | } 36 | 37 | } 38 | server { 39 | listen 443 ssl; 40 | 41 | ssl_certificate /path/to/certificate.pem; 42 | ssl_certificate_key /path/to/certificate.key; 43 | 44 | server_name app-api.example.com; 45 | 46 | location / { 47 | proxy_pass https://app-api.pixiv.net; 48 | proxy_ssl_server_name on; 49 | proxy_set_header Host app-api.pixiv.net; 50 | } 51 | } 52 | ``` 53 | 54 | Then edit the variables and replace with your url in `wrangler.toml`. 55 | 56 | ```text 57 | [vars] 58 | PIXIV_API_ENDPOINT = "app-api.example.com" 59 | PIXIV_OAUTH_ENDPOINT = "oauth.example.com" 60 | ``` 61 | 62 | 5. Upload the codes to Cloudflare Workers. 63 | 64 | ```text 65 | wrangler deploy 66 | ``` 67 | 68 | Demo: 69 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | addEventListener('fetch', event => { 2 | event.respondWith(handleRequest(event.request)) 3 | }) 4 | 5 | const credentials = { 6 | refresh_token: REFRESH_TOKEN, 7 | access_token: '', 8 | refresh_token_expiry: 0 9 | } 10 | 11 | function _checkRequest(request) { 12 | const url = new URL(request.url); 13 | if (/^\/(\d+)-(\d+).(jpg|png|gif)$/.test(url.pathname)) { 14 | let result = /^\/(\d+)-(\d+).(jpg|png|gif)$/.exec(url.pathname); 15 | return { 16 | is_valid: true, 17 | is_manga: true, 18 | pixiv_id: result[1], 19 | pixiv_page: result[2] - 1 20 | }; 21 | } else if (/^\/(\d+)\.(jpg|png|gif)/.test(url.pathname)) { 22 | let result = /^\/(\d+)\.(jpg|png|gif)/.exec(url.pathname); 23 | return { 24 | is_valid: true, 25 | is_manga: false, 26 | pixiv_id: result[1] 27 | }; 28 | } else { 29 | return { 30 | is_valid: false, 31 | is_manga: false 32 | }; 33 | } 34 | } 35 | 36 | function _parseFilenameFromUrl(url) { 37 | return url.substring(url.lastIndexOf('/') + 1) 38 | } 39 | 40 | async function _getToken() { 41 | if (Date.now() > credentials.refresh_token_expiry) { 42 | const url = new URL(`https://${PIXIV_OAUTH_ENDPOINT}/auth/token`); 43 | let formData = new FormData(); 44 | formData.append('grant_type', 'refresh_token'); 45 | formData.append('refresh_token', credentials.refresh_token); 46 | formData.append('client_id', 'MOBrBDS8blbauoSck0ZfDbtuzpyT'); 47 | formData.append('client_secret', 'lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj'); 48 | formData.append('hash_secret', '28c1fdd170a5204386cb1313c7077b34f83e4aaf4aa829ce78c231e05b0bae2c'); 49 | const refreshToken = new Request(url, { 50 | method: "POST", 51 | headers: { 52 | 'App-OS': 'ios', 53 | 'App-OS-Version': '10.3.1', 54 | 'App-Version': '6.7.1', 55 | 'User-Agent': 'PixivIOSApp/6.7.1 (iOS 10.3.1; iPhone8,1)', 56 | }, 57 | body: formData 58 | }) 59 | const res = await fetch(refreshToken); 60 | const apiResult = await res.json(); 61 | credentials.access_token = apiResult['response'].access_token; 62 | credentials.refresh_token_expiry = Date.now() + apiResult['response'].expires_in * 0.8 * 1000; 63 | return credentials.access_token; 64 | } else { 65 | return credentials.access_token; 66 | } 67 | } 68 | 69 | async function _callPixivApi(url, token) { 70 | const cache = caches.default; 71 | const cacheKey = new Request(new URL(url), { 72 | method: "GET", 73 | headers: { 74 | 'App-Version': '7.6.2', 75 | 'App-OS-Version': '12.2', 76 | 'App-OS': 'ios', 77 | 'Accept': 'application/json', 78 | 'User-Agent': 'PixivIOSApp/7.6.2 (iOS 12.2; iPhone9,1)' 79 | } 80 | }) 81 | const cacheKeyForRequest = new Request(new URL(url), { 82 | method: "GET", 83 | headers: { 84 | 'App-Version': '7.6.2', 85 | 'App-OS-Version': '12.2', 86 | 'App-OS': 'ios', 87 | 'Accept': 'application/json', 88 | 'User-Agent': 'PixivIOSApp/7.6.2 (iOS 12.2; iPhone9,1)', 89 | 'Authorization': 'Bearer ' + token 90 | } 91 | }) 92 | 93 | let cachedResponse = await cache.match(cacheKey) 94 | 95 | if (!cachedResponse) { 96 | const res = await fetch(cacheKeyForRequest) 97 | cachedResponse = new Response(res.body, res) 98 | cachedResponse.headers.set('Cache-Control', 'max-age=3600'); 99 | cachedResponse.headers.delete('Set-Cookie'); 100 | await cache.put(cacheKey, cachedResponse.clone()) 101 | } 102 | return cachedResponse.json(); 103 | } 104 | 105 | async function _getImage(url) { 106 | const cache = caches.default; 107 | const cacheKey = new Request(new URL(url), { 108 | method: "GET", 109 | headers: { 110 | 'Referer': 'http://www.pixiv.net/', 111 | 'User-Agent': 'Cloudflare Workers', 112 | } 113 | }) 114 | 115 | let cachedResponse = await cache.match(cacheKey) 116 | 117 | if (!cachedResponse) { 118 | const res = await fetch(cacheKey) 119 | cachedResponse = new Response(res.body, res) 120 | await cache.put(cacheKey, cachedResponse.clone()) 121 | } 122 | 123 | return cachedResponse; 124 | } 125 | 126 | async function handleRequest(request) { 127 | const checkRequest = _checkRequest(request); 128 | if (checkRequest.is_valid === false) { 129 | return new Response('404 Not Found', { 130 | status: 404 131 | }) 132 | } else if (checkRequest.is_manga === false) { // Normal mode 133 | const token = await _getToken(); 134 | // Using reverse proxy because pixiv is blocking some IP from cloudflare/google cloud. 135 | const pixivApi = await _callPixivApi(`https://${PIXIV_API_ENDPOINT}/v1/illust/detail?illust_id=${checkRequest.pixiv_id}`, token); 136 | 137 | if (pixivApi['error'] !== undefined) return new Response('這個作品可能已被刪除,或無法取得。', { 138 | status: 404 139 | }); // Not found 140 | if (pixivApi['illust']['page_count'] > 1) return new Response(`這個作品ID中有 ${pixivApi['illust']['page_count']} 張圖片,需要指定頁數才能正確顯示。`, { 141 | status: 404 142 | }); // This Pixiv ID is manga mode, must to specify which page. 143 | 144 | let image = await _getImage(pixivApi['illust']['meta_single_page']['original_image_url']); 145 | image = new Response(image.body, image); 146 | image.headers.set('X-Origin-URL', pixivApi['illust']['meta_single_page']['original_image_url']); 147 | image.headers.set('X-Access-Token-TS', credentials.refresh_token_expiry); 148 | image.headers.set('Content-Disposition', 'inline; filename="' + _parseFilenameFromUrl(pixivApi['illust']['meta_single_page']['original_image_url']) + '"'); 149 | image.headers.delete('Via'); 150 | return image; 151 | } else if (checkRequest.is_manga === true) { // Manga mode 152 | if (checkRequest.pixiv_page < 0) return new Response('頁數不得為0。', { 153 | status: 404 154 | }); // Specified page is 0. 155 | 156 | const token = await _getToken(); 157 | const pixivApi = await _callPixivApi(`https://${PIXIV_API_ENDPOINT}/v1/illust/detail?illust_id=${checkRequest.pixiv_id}`, token); 158 | 159 | if (pixivApi['error'] !== undefined) return new Response('這個作品可能已被刪除,或無法取得。', { 160 | status: 404 161 | }); // Not found 162 | if (pixivApi['illust']['page_count'] === 1) return new Response('這個作品ID中有只有一張圖片,不需要指定是第幾張圖片。', { 163 | status: 404 164 | }); // This Pixiv ID is Normal mode but the page is specified. 165 | if (checkRequest.pixiv_page + 1 > pixivApi['illust']['page_count'] || checkRequest.pixiv_page < 0) return new Response(`這個作品ID中有 ${pixivApi['illust']['page_count']} 張圖片,您指定的頁數已超過這個作品ID中的頁數。`, { 166 | status: 404 167 | }); // The specified page is more than total pages of this ID. 168 | 169 | let image = await _getImage(pixivApi['illust']['meta_pages'][checkRequest.pixiv_page]['image_urls']['original']); 170 | image = new Response(image.body, image); 171 | image.headers.set('X-Origin-URL', pixivApi['illust']['meta_pages'][checkRequest.pixiv_page]['image_urls']['original']); 172 | image.headers.set('X-Access-Token-TS', credentials.refresh_token_expiry); 173 | image.headers.set('Content-Disposition', 'inline; filename="' + _parseFilenameFromUrl(pixivApi['illust']['meta_pages'][checkRequest.pixiv_page]['image_urls']['original']) + '"'); 174 | image.headers.delete('Via'); 175 | return image; 176 | } 177 | } 178 | --------------------------------------------------------------------------------