├── .gitignore ├── bin └── www.js ├── setup ├── env.example └── redisq.service ├── LICENSE.md ├── package.json ├── www └── controllers │ ├── root.js │ ├── object.js │ ├── queue.js │ └── listen.js ├── util └── filters.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .env 3 | -------------------------------------------------------------------------------- /bin/www.js: -------------------------------------------------------------------------------- 1 | const app = require('fundamen')('www'); 2 | -------------------------------------------------------------------------------- /setup/env.example: -------------------------------------------------------------------------------- 1 | PORT=10000 2 | REDIS_LOAD=true 3 | 4 | #pass=NOTTHEPASSWORDDUH 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # zKillboard License 2 | 3 | ## License 4 | zKillboard uses the AGPLv3 license. Full license text is available in the `AGPL.md` file. 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redisq.zkillbaord.com", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "node ./bin/www" 7 | }, 8 | "dependencies": { 9 | "fundamen": "^0.2.*", 10 | "node-fetch": "^2.7.0" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /setup/redisq.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=redisq 3 | 4 | [Service] 5 | ExecStart=/usr/bin/node ./bin/www.js 6 | Restart=always 7 | User=www-data 8 | Group=www-data 9 | Environment=PATH=/usr/bin:/usr/local/bin 10 | EnvironmentFile=/var/www/redisq.zkillboard.com/.env 11 | WorkingDirectory=/var/www/redisq.zkillboard.com 12 | StandardOutput=append:/var/log/redisq/output.log 13 | StandardError=append:/var/log/redisq/error.log 14 | 15 | [Install] 16 | WantedBy=multi-user.target 17 | -------------------------------------------------------------------------------- /www/controllers/root.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | paths: '/', 5 | get: get 6 | } 7 | 8 | let ocount = -1, lcount = -1; 9 | 10 | async function get(req, res, app) { 11 | try { 12 | if (ocount == -1) { 13 | const objects = await app.redis.keys('redisQ:object:*'); 14 | const listeners = await app.redis.keys('redisQ:list:*'); 15 | ocount = objects.length; 16 | lcount = listeners.length; 17 | setTimeout(() => { ocount = -1; lcount = -1 }, 300000); 18 | } 19 | 20 | return { json: { listeners: lcount , objects: ocount }, cors: '*', ttl: 300 }; 21 | } catch (e) { 22 | console.error(e); 23 | return {status_code: 503}; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /www/controllers/object.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | paths: '/object.php', 5 | get: get 6 | } 7 | 8 | const default_ttl = parseInt(process.env.default_ttl || 10800); 9 | 10 | const cutoffDate = new Date('2025-12-01T00:00:00Z'); 11 | 12 | async function get(req, res, app) { 13 | const objectID = (req.query.objectID || '').trim(); 14 | if (objectID.length == 0) return {status_code: 404}; 15 | if (objectID == 'null') return { json: { package: null }, 'cors': '*' }; 16 | 17 | try { 18 | const object = await app.redis.get(`redisQ:object:${objectID}`); 19 | if (object == null) { 20 | return { status_code: 404 }; 21 | } 22 | 23 | let o = JSON.parse(object); 24 | 25 | // If the current date is on or after 2025-12-01, 26 | // we are going to remove the killmail section 27 | // this code chunk will be removed in a future release 28 | const currentDate = new Date(); 29 | if (currentDate >= cutoffDate) { 30 | if (o.killmail) { 31 | delete o.killmail; 32 | } 33 | } 34 | 35 | return { json: { package: o }, ttl: default_ttl, 'cors': '*' }; 36 | } catch (e) { 37 | console.error(e); 38 | return {status_code: 503}; 39 | } 40 | } 41 | 42 | -------------------------------------------------------------------------------- /www/controllers/queue.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs').promises; 4 | const fetch = require('node-fetch'); 5 | 6 | module.exports = { 7 | paths: '/queue.php', 8 | post: post, 9 | } 10 | 11 | const default_ttl = parseInt(process.env.default_ttl || 10800); 12 | 13 | async function post(req, res, app) { 14 | try { 15 | const pass = req.body.pass; 16 | let sack = decodeURI(req.body.package); // can't use the word package, so we'll use sack! 17 | 18 | if (process.env.pass !== pass) return { status_code: 401 }; 19 | if (sack === null) return { status_code: 400 }; 20 | 21 | let json = JSON.parse(sack); 22 | if (!json.killmail && json.zkb.href) { 23 | let res = await fetch(json.zkb.href, { headers: { 'User-Agent': 'RedisQ' } }); 24 | if (res.status != 200) { 25 | return { status_code: 400 }; 26 | } 27 | json.killmail = await res.json(); 28 | sack = JSON.stringify(json); 29 | } 30 | 31 | const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 7); 32 | const objectID = 'redisQ:object:' + id; 33 | 34 | const multi = await app.redis.multi(); 35 | await multi.setex(objectID, default_ttl, sack); 36 | for (let queueID of await app.redis.keys('redisQ:queue:*')) { 37 | const listkey = 'redisQ:list:' + queueID.replace('redisQ:queue:', ''); 38 | await multi.lpush(listkey, objectID); 39 | await multi.expire(listkey, default_ttl); 40 | } 41 | await multi.exec(); 42 | 43 | return { json: { success: true }, 'cors': '*' } 44 | } catch (e) { 45 | console.log(e); 46 | await app.sleep(1000); 47 | return { staus_code: 503 }; 48 | } 49 | } 50 | 51 | -------------------------------------------------------------------------------- /util/filters.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const OP_REGEX = /(<=|>=|!=|=|<|>)/; 4 | 5 | // Parse filter string into rules 6 | function parseFilters(filterStr) { 7 | if (!filterStr) return null; 8 | 9 | const hasAnd = filterStr.includes(";"); 10 | const hasOr = filterStr.includes(","); 11 | 12 | if (hasAnd && hasOr) { 13 | throw new Error("Cannot mix ; and , in filter string"); 14 | } 15 | 16 | const operator = hasAnd ? "AND" : hasOr ? "OR" : "AND"; 17 | const parts = filterStr.split(hasAnd ? ";" : ","); 18 | 19 | 20 | const rules = parts.map(p => { 21 | const match = p.match(OP_REGEX); 22 | if (!match) throw new Error("Invalid filter format, use keyvalue"); 23 | 24 | const [key, val] = p.split(match[0]); 25 | return { 26 | key: key.trim(), 27 | op: match[0], 28 | val: isNaN(val.trim()) ? val.trim() : Number(val.trim()) 29 | }; 30 | }); 31 | 32 | return { operator, rules }; 33 | } 34 | 35 | // Recursive traversal — collects ALL values for a given key, including inside arrays 36 | function findValues(obj, key) { 37 | let results = []; 38 | 39 | if (Array.isArray(obj)) { 40 | for (const el of obj) { 41 | results = results.concat(findValues(el, key)); 42 | } 43 | } else if (obj && typeof obj === "object") { 44 | for (const k in obj) { 45 | if (k === key) results.push(obj[k]); 46 | results = results.concat(findValues(obj[k], key)); 47 | } 48 | } 49 | 50 | return results; 51 | } 52 | 53 | // Comparison logic 54 | function compare(op, a, b) { 55 | // auto-cast strings to numbers if both look numeric 56 | const isNum = !isNaN(a) && !isNaN(b); 57 | if (isNum) { 58 | a = Number(a); 59 | b = Number(b); 60 | } 61 | switch (op) { 62 | case "=": return a == b; 63 | case "!=": return a != b; 64 | case "<": return a < b; 65 | case "<=": return a <= b; 66 | case ">": return a > b; 67 | case ">=": return a >= b; 68 | default: return false; 69 | } 70 | } 71 | 72 | // Check if a package matches the rules 73 | function matchesFilter(pkg, filter) { 74 | if (!filter) return true; 75 | const { operator, rules } = filter; 76 | 77 | const results = rules.map(r => { 78 | const values = findValues(pkg, r.key); 79 | return values.some(v => compare(r.op, v, r.val)); 80 | }); 81 | 82 | return operator === "AND" ? results.every(Boolean) : results.some(Boolean); 83 | } 84 | 85 | module.exports = { 86 | parseFilters, 87 | findValues, 88 | compare, 89 | matchesFilter 90 | }; -------------------------------------------------------------------------------- /www/controllers/listen.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { parseFilters, matchesFilter } = require('../../util/filters.js'); 4 | 5 | module.exports = { 6 | paths: '/listen.php', 7 | get: get 8 | } 9 | 10 | const null_redirect = `/object.php?objectID=null`; 11 | const default_ttl = parseInt(process.env.default_ttl || 10800); 12 | 13 | async function get(req, res, app) { 14 | const queueID = (req.query.queueID || '').trim(); 15 | if (queueID.length == 0) return { status_code: 429 }; 16 | 17 | const lockKey = `redisQ:lock:${queueID}`; 18 | let lockAcquired = false; 19 | try { 20 | lockAcquired = await app.redis.set(lockKey, "1", "NX", "EX", 30); 21 | if (lockAcquired !== 'OK') { 22 | return {status_code: 429}; 23 | } 24 | lockAcquired = true; 25 | 26 | const ttw = Math.min(10, Math.max(1, parseInt(req.query.ttw || 10))); 27 | const ttl = Math.max(1, Math.min(default_ttl, parseInt(req.query.ttl || default_ttl))); 28 | 29 | let filter = null; 30 | try { 31 | filter = parseFilters(req.query.filter); 32 | } catch (err) { 33 | return { status_code: 400 }; 34 | } 35 | let sackID, t = 0; 36 | 37 | await app.redis.setex('redisQ:queue:' + queueID, ttl, "."); 38 | do { 39 | sackID = await app.redis.rpop('redisQ:list:' + queueID); 40 | if (sackID) { 41 | let raw = await app.redis.get(sackID); 42 | if (raw) { 43 | const object = JSON.parse(raw); 44 | if (!matchesFilter(object, filter)) { 45 | sackID = null; 46 | continue; 47 | } 48 | } else { 49 | sackID = null; 50 | continue; 51 | } 52 | } else { 53 | await app.sleep(1000); 54 | t = t + 1; 55 | } 56 | } while (sackID == null && t <= ttw); 57 | if (sackID != null) { 58 | const split = sackID.split(':'); 59 | const objectID = split[2]; 60 | const redirect = `/object.php?objectID=${objectID}`; 61 | await app.sleep(Math.floor(Math.random() * 1000) + 500); 62 | return {status_code: 302, 'cors': '*', redirect: redirect}; 63 | } 64 | return {status_code: 302, 'cors': '*', redirect: null_redirect }; 65 | } catch (e) { 66 | console.error(e); 67 | return {status_code: 503}; 68 | } finally { 69 | try { 70 | if (lockAcquired) await app.redis.del(lockKey); 71 | } catch (ee) { 72 | // we've got problems here... 73 | process.kill(process.pid, 'SIGINT'); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!IMPORTANT] 2 | > # 🚨RedisQ Breaking Change - NOW COMPLETE AS OF Dec. 1, 2025 3 | > (leaving this here for the time being) 4 | > Hey devs — usage of RedisQ has skyrocketed lately, and the data volume going out now far exceeds what websocket + RedisQ handled just a few months ago. 5 | > To keep things efficient and sustainable, I’m planning to remove the embedded killmail data from RedisQ objects. Going forward, tools will need to fetch the killmail directly from the ESI API using the provided killmail/hash. 6 | > I know this will break some (probably all) existing integrations, so consider this your heads-up to prepare for the change. 7 | > More details (and timing) will follow once I finalize the rollout plan. The proposed change will remove the killmail object and look like this: 8 | ```json 9 | { 10 | "package": { 11 | "killID": 130678514, 12 | "zkb": { 13 | "locationID": 40030969, 14 | "hash": "145c457c34ce9c9e8d67e942e764d8f439b22271", 15 | "fittedValue": 3373417589.45, 16 | "droppedValue": 2474643537.39, 17 | "destroyedValue": 900440852.06, 18 | "totalValue": 3375084389.45, 19 | "points": 13, 20 | "npc": false, 21 | "solo": false, 22 | "awox": false, 23 | "labels": [ 24 | "tz:ru", 25 | "cat:6", 26 | "#:5+", 27 | "pvp", 28 | "loc:nullsec", 29 | "isk:1b+" 30 | ], 31 | "href": "https://esi.evetech.net/v1/killmails/130678514/145c457c34ce9c9e8d67e942e764d8f439b22271/" 32 | } 33 | } 34 | } 35 | ``` 36 | 37 | # RedisQ 38 | 39 | A simple queue service using Redis as the backend. All you have to do is point your code to https://zkillredisq.stream/listen.php. Then parse the JSON that you receive and do anything you like with it. 40 | 41 | If no killmail has come in for 10 seconds, you'll receive a null package, example: 42 | {"package":null} 43 | 44 | The server will remember your queueID for up to 3 hours, therefore, pauses in your code will not cause you to miss out on killmails. 45 | 46 | ##### Do I need Redis to use this service? 47 | 48 | You don't need Redis to use this service, its only called RedisQ because the service itself is powered by Redis. 49 | 50 | ##### How do I identify myself? 51 | 52 | RedisQ will use the parameter queueID to identify you. This field is required! Example: 53 | 54 | https://zkillredisq.stream/listen.php?queueID=Voltron9000 55 | 56 | ##### How can I wait less than 10 seconds if there isn't a new killmail? 57 | 58 | By default, RedisQ will wait up to 10 seconds for a new killmail to come in. To wait less than this 10 seconds, you can use the ttw parameter, which is short for timeToWait. Example: 59 | 60 | https://zkillredisq.stream/listen.php?queueID=Voltron9000&ttw=1 61 | 62 | And yes, you can combine the ttw and queueID parameters. The code will enforce a minimum of 1 and a maximum of 10 seconds. 63 | 64 | ##### Something changed with the way RedisQ works, help? 65 | 66 | As of August, 2025, a change has been implemented such that the /listen.php endpoint redirects to /object.php with an objectID for your next package. Be sure that whatever tool you are using can accommodate redirects. 67 | 68 | https://zkillredisq.stream/object.php?objectID=NotRealObjectID 69 | 70 | #### Limitations 71 | 72 | - You may have one (1) request being handled at a time per queueID. Additional requests being served while another request is already polling will resolve with http code 429. 73 | - You may request at a limit of two (2) requests per second per IP address. This limitations is enforced by CloudFlare – if you exceed this limitation your request will resolve with http code 429. 74 | 75 | #### FAQ 76 | 77 | ###### So, this seems too easy. What do I have to do again? 78 | 79 | It really is very, very simple. All you have to do is point something at https://zkillredisq.stream/listen.php, that can be curl, file_get_contents, wget, etc. etc. Here's an example of getting a killmail with PHP 80 | 81 | ``` 82 | $raw = file_get_contents("https://zkillredisq.stream/listen.php?queueID=YourIdHere"); 83 | $json = json_decode($raw, true); 84 | $killmail = $json['package']; 85 | ``` 86 | 87 | That's it, really. You now have a killmail. Put that into a loop and you can keep feeding yourself all the killmails as zKillboard gets them. 88 | 89 | ###### Can I have pauses between requests without missing any killmails? 90 | 91 | Yes, RedisQ identifies you based on your queueID and will remember you for up to 3 hours. So you can setup cron jobs to run every minute, 5 minutes, 15 minutes, etc. and not worry about missing any of the killmails. 92 | 93 | ###### Can I use more than one connection on RedisQ? 94 | 95 | (This sections is currently deprecated, perhaps only temporarily) 96 | 97 | Only one connection at a time is allowed. If you try for more the extra connections will receive a http 429 error. Too many 429 errors will cause your IP and userid (if provided) to be temporarily banned for several hours. 98 | 99 | ###### Can I subscribe to just my pilot's / character's / alliance's killmails? 100 | 101 | Yes! Use the filter parameter. See the section below on RedisQ Filter Rules. 102 | 103 | https://zkillredisq.stream/listen.php?queueID=Voltron9000&filter=alliance_id=434243723 104 | 105 | If you pass an invalid filter then a 400 Invalid Request error is thrown. 106 | 107 | ###### Seriously? Why do this and not use websockets or something like that? 108 | 109 | Websockets are great, sure, but I wanted to write something that was damn easy to implement in any language. RedisQ isn't trying to be fancy like websockets, it is only trying to disemminate killmails in a quick and very simple fashion. 110 | 111 | ###### Why is it called RedisQ? 112 | 113 | Because I used Redis to implement what I was trying to do, it's a queue type service, and so I went with the completely unoriginal name RedisQ. 114 | 115 | ###### Why are you using .php extension when RedisQ isn't using PHP? 116 | 117 | The initial version of RedisQ utilized PHP as the backend language of choice. However, a subsequent rewrite is now using NodeJS. To keep things simple and allow for great backwards compatibility the endpoints kept their .php extension. 118 | 119 | ###### I thought the URL was redisq.zkillboard.com? 120 | 121 | The URL was changed in May, 2025 to zkillredisq.stream. 122 | 123 | ###### How do I say RedisQ? 124 | 125 | Everyone says it different, but I say it like red-is-q. You can say it however you want though. 126 | 127 | # RedisQ Filter Rules 128 | 129 | ## 1. Operators 130 | - `=` : equals 131 | - `!=` : not equals 132 | - `<` : less than 133 | - `<=` : less than or equal 134 | - `>` : greater than 135 | - `>=` : greater than or equal 136 | 137 | ## 2. Chaining Rules 138 | - **AND** (`;`): all conditions must match 139 | - Example: 140 | ``` 141 | alliance_id=1234;damage_done>=500 142 | ``` 143 | → Matches only if both conditions are true. 144 | 145 | - **OR** (`,`): at least one condition must match 146 | - Example: 147 | ``` 148 | alliance_id=1234,4321,5678 149 | ``` 150 | → Matches if any of the listed conditions are true. 151 | 152 | - **Important:** You cannot mix `;` and `,` in the same filter string. 153 | 154 | ## 3. Matching Behavior 155 | - Works on **any key** inside the RedisQ `package` object (deeply nested). 156 | - If a key’s value is an **array**, all elements of the array are searched. 157 | - Values are compared as **numbers** if both sides are numeric, otherwise as strings. 158 | 159 | ## 4. Examples 160 | - `https://zkillredisq.stream/listen.php?queueID=Voltron9000&filter=character_id=5678` 161 | → true if any `character_id` equals 5678 162 | 163 | - `https://zkillredisq.stream/listen.php?queueID=Voltron9000&filter=damage_done>=1000` 164 | → true if any `damage_done` is ≥ 1000 165 | 166 | - `https://zkillredisq.stream/listen.php?queueID=Voltron9000&filter=alliance_id!=9999` 167 | → true if no matching `alliance_id` equals 9999 168 | 169 | - `https://zkillredisq.stream/listen.php?queueID=Voltron9000&filter=alliance_id=1234;damage_done>500` 170 | → both must match (AND) 171 | 172 | - `https://zkillredisq.stream/listen.php?queueID=Voltron9000&filter=character_id=1111,character_id=2222` 173 | → matches if either character ID is found 174 | 175 | - `https://zkillredisq.stream/listen.php?queueID=Voltron9000&filter=labels=marked` 176 | → matches if any key `labels`, which could be a key or an array, equals or contains the value marked 177 | --------------------------------------------------------------------------------