feat: collector task checklist.
chore: docs updated, gitignore updated.
Docs: TARKOV_DEV_API.md fully explains tarkov.dev's api for future coding agents and forgetful people llike me.
This commit is contained in:
serversdwn
2026-02-21 09:41:06 +00:00
parent 7fa00d731c
commit 68005b1cb0
10 changed files with 943 additions and 14 deletions

7
.claude/settings.json Normal file
View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(curl:*)"
]
}
}

34
.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# Database — source of truth lives locally, not in git
*.db
*.db-shm
*.db-wal
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
.eggs/
# Virtual environments
venv/
.venv/
env/
# Editor / IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Secrets / env
.env
*.env

120
README.md
View File

@@ -1,14 +1,112 @@
##Place hoder, for this here personalized tarkov DB im building.
# OnlyScavs v0.1.1
A personal Escape from Tarkov database and toolkit. The goal is to maintain a **local SQLite database that I fully control** — tarkov.dev is used only as a one-time (or on-demand) data source to seed it. Once imported, the local DB is the source of truth and can be edited, annotated, and extended freely without relying on any external API being up or accurate.
##Items to be tracked = ##
**Weight Management Mostly**
--Weapons and weapons parts
--helmets + armor and rigs
--backpacks
---
**Keys**
--full list w/ locations and whats behind the lock
-- vendor price
-- My personal 0-4 priority scale.
-- Flag useless keys.
## What it does
- **Key tracker** — full list of keys with personal priority ratings (IGNORE / LOW / MED / HIGH / SUPER), map tagging, notes, and quest flags
- **Collector checklist** — all 255 quests required to unlock *The Collector* (Kappa), with per-quest done/not-done tracking and a progress bar
---
## Setup
### 1. Install dependencies
```bash
pip install flask requests
```
### 2. Initialize the database
Run the imports in order. Each script creates its own tables if they don't exist.
```bash
# Import all keys from tarkov.dev into local DB
python3 import_keys.py
# Import all quests/tasks and their dependency graph
python3 import_quests.py
```
Then apply the maps migration (adds maps table + keymap relationships):
```bash
python3 -c "
import sqlite3
conn = sqlite3.connect('tarkov.db')
conn.executescript(open('migrations_v1.sql').read())
conn.commit()
conn.close()
"
```
> After this, the DB is yours. You don't need tarkov.dev running to use the app.
### 3. Run the app
```bash
python3 app.py
```
Open **http://127.0.0.1:5000**
---
## Re-importing data
The import scripts can be re-run any time to pull fresh data from tarkov.dev (e.g. after a big patch). They use `INSERT OR REPLACE` / `INSERT OR IGNORE` so they won't duplicate records, but **any manual edits to imported fields (name, wiki_link, etc.) will be overwritten**. Personal data (ratings, notes, map tags, quest progress) is stored in separate tables and is safe.
```bash
python3 import_keys.py # refresh key list
python3 import_quests.py # refresh quest list + dependency graph
```
---
## Project structure
```
onlyscavs/
├── app.py # Flask web app
├── import_keys.py # Seeds keys table from tarkov.dev
├── import_quests.py # Seeds quests + quest_deps tables from tarkov.dev
├── migrations_v1.sql # Maps table + key_maps + used_in_quest flag
├── tarkov.db # Local SQLite DB (gitignored — stays on your machine)
├── templates/
│ ├── index.html # Key ratings UI
│ └── collector.html # Collector checklist UI
└── TARKOV_DEV_API.md # tarkov.dev GraphQL API reference
```
---
## Database schema
| Table | Purpose |
|---|---|
| `keys` | All key items (seeded from tarkov.dev, then local) |
| `key_ratings` | Personal ratings, notes, quest flags per key |
| `key_maps` | Which maps each key is used on |
| `maps` | Map list |
| `quests` | All tasks/quests (seeded from tarkov.dev) |
| `quest_deps` | Quest prerequisite graph (which quest unlocks which) |
| `quest_progress` | Personal done/not-done state per quest |
---
## Routes
| Route | Description |
|---|---|
| `GET /` | Key list with filters, sorting, and inline rating forms |
| `POST /rate` | Save rating for a single key |
| `POST /rate_all` | Save ratings for all visible keys |
| `GET /collector` | Collector checklist with progress bar |
| `POST /collector/toggle` | Mark a quest done or not done |
---
## Planned
- Weight/loadout tracker (weapons, armor, rigs, backpacks)
- Vendor price comparison
- Key location notes (what's behind the door)

458
TARKOV_DEV_API.md Normal file
View File

@@ -0,0 +1,458 @@
# tarkov.dev GraphQL API Reference
> Community-made, real-time EFT data. No API key required.
- **Endpoint:** `https://api.tarkov.dev/graphql`
- **Playground:** `https://api.tarkov.dev/` (interactive explorer with autocomplete)
- **Protocol:** GraphQL over HTTP POST
- **Auth:** None — completely open
- **Source:** https://github.com/the-hideout/tarkov-api
---
## How to query (Python)
```python
import requests
API_URL = "https://api.tarkov.dev/graphql"
def gql(query):
r = requests.post(API_URL, json={"query": query})
r.raise_for_status()
data = r.json()
if "errors" in data:
raise RuntimeError(data["errors"])
return data["data"]
```
---
## Common arguments (most queries accept these)
| Arg | Type | Notes |
|------------|----------------|--------------------------------------------|
| `lang` | `LanguageCode` | e.g. `en`, `ru`, `de`, `fr`, `es`, `zh` |
| `gameMode` | `GameMode` | `regular` or `pve` |
| `limit` | `Int` | Max results to return |
| `offset` | `Int` | Pagination offset |
---
## All available root queries
```
achievements(lang, limit, offset)
ammo(lang, gameMode, limit, offset)
archivedItemPrices(id, limit, offset)
barters(lang, gameMode, limit, offset)
bosses(lang, gameMode, name, limit, offset)
crafts(lang, gameMode, limit, offset)
fleaMarket(lang, gameMode)
goonReports(lang, gameMode, limit, offset)
handbookCategories(lang, limit, offset)
hideoutStations(lang, gameMode, limit, offset)
historicalItemPrices(id, days, lang, gameMode, limit, offset)
item(id, normalizedName, lang, gameMode)
items(ids, name, names, type, types, categoryNames, handbookCategoryNames, bsgCategoryId, bsgCategoryIds, bsgCategory, lang, gameMode, limit, offset)
itemCategories(lang, limit, offset)
itemPrices(id, gameMode, limit, offset)
lootContainers(lang, limit, offset)
maps(lang, gameMode, name, enemies, limit, offset)
mastering(lang)
playerLevels()
prestige(lang, gameMode)
questItems(lang)
skills(lang)
stationaryWeapons(lang, limit, offset)
status()
task(id, lang, gameMode)
tasks(faction, lang, gameMode, limit, offset)
traders(lang, gameMode, limit, offset)
```
---
## Query examples
### Server status
```graphql
{
status {
currentStatuses {
name
message
status
}
messages {
time
type
content
solveTime
}
}
}
```
### Single item by name
```graphql
{
items(name: "colt m4a1") {
id
name
shortName
basePrice
avg24hPrice
changeLast48hPercent
width
height
weight
wikiLink
iconLink
gridImageLink
sellFor {
price
currency
source
}
buyFor {
price
currency
source
}
}
}
```
### Items by type (e.g. all keys)
```graphql
{
items(types: [keys]) {
id
name
shortName
wikiLink
gridImageLink
properties {
... on ItemPropertiesKey {
uses
}
}
}
}
```
Valid `ItemType` values include: `keys`, `ammo`, `armor`, `backpack`, `gun`, `headwear`, `rig`, `medical`, `food`, `barter`, `container`, `grenade`, `headphones`, `knife`, `stimulator`, `suppressor`, `weapon`
### All tasks / quests
```graphql
{
tasks {
id
name
wikiLink
minPlayerLevel
kappaRequired
trader {
name
}
taskRequirements {
task {
id
name
}
status
}
objectives {
id
type
description
maps {
name
normalizedName
}
... on TaskObjectiveItem {
item { name shortName }
count
foundInRaid
}
... on TaskObjectiveShoot {
targetNames
count
}
... on TaskObjectiveLocation {
locationNames
}
... on TaskObjectiveQuestItem {
questItem { name }
count
}
... on TaskObjectiveSkill {
skillLevel { name level }
}
... on TaskObjectiveTraderLevel {
trader { name }
level
}
}
startRewards {
items { item { name } count }
traderStanding { trader { name } standing }
}
finishRewards {
items { item { name } count }
experience
traderStanding { trader { name } standing }
offerUnlock { trader { name } item { name } }
}
}
}
```
> **Note:** `objectives` returns a `TaskObjective` **interface** — use inline fragments (`... on TypeName`) to access type-specific fields.
### Single task by ID
```graphql
{
task(id: "5936d90786f7742b1420ba5b") {
name
trader { name }
taskRequirements { task { id name } }
}
}
```
### Ammo stats
```graphql
{
ammo {
item { name shortName }
caliber
damage
armorDamage
penetrationPower
penetrationChance
fragmentationChance
initialSpeed
lightBleedModifier
heavyBleedModifier
}
}
```
### Traders
```graphql
{
traders {
id
name
normalizedName
currency { name }
levels {
level
requiredPlayerLevel
requiredReputation
requiredCommerce
cashOffers {
item { name }
price
currency
minTraderLevel
taskUnlock { name }
}
}
barters {
requiredItems { item { name } count }
rewardItems { item { name } count }
minTraderLevel
taskUnlock { name }
}
}
}
```
### Hideout stations
```graphql
{
hideoutStations {
id
name
levels {
level
constructionTime
itemRequirements { item { name } count }
stationLevelRequirements { station { name } level }
skillRequirements { skill { name } level }
traderRequirements { trader { name } requirementType value }
bonuses { type value }
}
}
}
```
### Maps
```graphql
{
maps {
id
name
normalizedName
raidDuration
players
bosses {
boss { name }
spawnChance
spawnLocations { name chance }
escorts { boss { name } amount { count chance } }
}
extracts {
name
faction
switches { name }
}
}
}
```
### Crafts
```graphql
{
crafts {
station { name }
level
duration
requiredItems { item { name } count }
rewardItems { item { name } count }
taskUnlock { name }
}
}
```
### Barters
```graphql
{
barters {
trader { name }
level
taskUnlock { name }
requiredItems { item { name } count }
rewardItems { item { name } count }
}
}
```
### Flea market info
```graphql
{
fleaMarket {
enabled
minPlayerLevel
sellOfferFeeRate
sellRequirementFeeRate
}
}
```
### Achievements
```graphql
{
achievements {
id
name
description
hidden
rarity
playersCompletedPercent
side
}
}
```
### Goon reports (roaming boss locations)
```graphql
{
goonReports {
map { name }
timestamp
}
}
```
---
## Key types reference
### Item fields
```
id, name, shortName, normalizedName
basePrice, avg24hPrice, low24hPrice, high24hPrice
changeLast48h, changeLast48hPercent
lastLowPrice, lastOfferCount
width, height, weight, velocity
wikiLink, iconLink, gridImageLink, inspectImageLink, image8xLink
types[] ItemType enum values this item belongs to
sellFor[] { price, currency, source, vendor { name } }
buyFor[] { price, currency, source, vendor { name } }
bartersFor[] Barter objects
bartersUsing[] Barter objects
usedInTasks[] Task objects
receivedFromTasks[] Task objects
properties ItemProperties union (see below)
category { id, name }
```
### ItemProperties union types
Use `... on ItemPropertiesX { }` inline fragments:
- `ItemPropertiesKey``uses`
- `ItemPropertiesAmmo``caliber, damage, penetrationPower, ...`
- `ItemPropertiesArmor``class, durability, material, zones[]`
- `ItemPropertiesArmorAttachment``class, durability, material, zones[], headCoverage`
- `ItemPropertiesBackpack``capacity, grids[]`
- `ItemPropertiesChestRig``capacity, class, durability, zones[]`
- `ItemPropertiesWeapon``caliber, fireRate, ergonomics, recoil...`
- `ItemPropertiesMagazine``capacity, caliber, ergonomics`
- `ItemPropertiesScope``ergonomics, recoil, zoomLevels[]`
- `ItemPropertiesMedKit``uses, useTime, hpCostLightBleeding, hpCostHeavyBleeding`
- `ItemPropertiesFood``energy, hydration, stimEffects[]`
- `ItemPropertiesStimulator``stimEffects[]`
- `ItemPropertiesHelmet``class, durability, material, headZones[], deafening`
- `ItemPropertiesGlasses``class, durability, blindnessProtection`
- `ItemPropertiesNightVision``intensity, noiseIntensity, noiseScale`
- `ItemPropertiesContainer``capacity`
- `ItemPropertiesGrenade``type, fuse, fragments, minExplosionDistance, maxExplosionDistance`
### Task / quest fields
```
id, name, normalizedName
wikiLink
minPlayerLevel
kappaRequired bool, whether needed for Kappa (The Collector)
restartable
trader { id, name }
map { id, name } nullable, map-specific tasks only
experience
taskRequirements[] { task { id, name }, status[] }
objectives[] TaskObjective interface (use inline fragments)
startRewards TaskRewards
finishRewards TaskRewards
```
### TaskRewards fields
```
items[] { item { name }, count }
traderStanding[] { trader { name }, standing }
offerUnlock[] { trader { name }, level, item { name } }
skillLevelReward[] { name, level }
craftUnlock[] craft objects
```
---
## How we use it in this project
| Script | Query | Purpose |
|---|---|---|
| [import_keys.py](import_keys.py) | `items(types: [keys])` | Fetches all key items into `keys` table |
| [import_quests.py](import_quests.py) | `tasks { id name wikiLink trader taskRequirements }` | Fetches all tasks and their dependencies into `quests` + `quest_deps` tables |
The Collector prerequisite tree is computed from `quest_deps` using a recursive SQL CTE in [app.py](app.py) at `/collector`.

47
app.py
View File

@@ -194,5 +194,52 @@ def rate_all():
return redirect(base_url)
@app.route("/collector")
def collector():
conn = get_db()
collector = conn.execute(
"SELECT id FROM quests WHERE name = 'Collector'"
).fetchone()
if not collector:
conn.close()
return "Run import_quests.py first to populate quest data.", 503
# Recursive CTE to get all transitive prerequisites
prereqs = conn.execute("""
WITH RECURSIVE deps(quest_id) AS (
SELECT depends_on FROM quest_deps WHERE quest_id = ?
UNION
SELECT qd.depends_on FROM quest_deps qd
JOIN deps d ON qd.quest_id = d.quest_id
)
SELECT q.id, q.name, q.trader, q.wiki_link,
COALESCE(qp.done, 0) AS done
FROM quests q
JOIN deps d ON q.id = d.quest_id
LEFT JOIN quest_progress qp ON q.id = qp.quest_id
ORDER BY q.trader, q.name
""", (collector["id"],)).fetchall()
conn.close()
total = len(prereqs)
done = sum(1 for q in prereqs if q["done"])
return render_template("collector.html", quests=prereqs, total=total, done=done)
@app.route("/collector/toggle", methods=["POST"])
def collector_toggle():
quest_id = request.form["quest_id"]
done = 1 if request.form.get("done") == "1" else 0
conn = get_db()
conn.execute("""
INSERT INTO quest_progress (quest_id, done) VALUES (?, ?)
ON CONFLICT(quest_id) DO UPDATE SET done = excluded.done
""", (quest_id, done))
conn.commit()
conn.close()
return redirect(url_for("collector"))
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)

View File

@@ -82,10 +82,10 @@ def upsert_keys(conn, keys):
else:
cursor.execute(
"""
INSERT INTO keys (api_id, name, short_name, weight_kg, uses)
VALUES (?, ?, ?, ?, ?)
INSERT INTO keys (api_id, name, short_name, weight_kg, uses, wiki_url, grid_image_url)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(api_id, name, short_name, weight, uses, icon_url, wiki_url)
(api_id, name, short_name, weight, uses, wiki_url, grid_image_url)
)
inserted += 1

141
import_quests.py Normal file
View File

@@ -0,0 +1,141 @@
import requests
import sqlite3
DB_PATH = "tarkov.db"
API_URL = "https://api.tarkov.dev/graphql"
QUERY = """
{
tasks {
id
name
wikiLink
trader {
name
}
taskRequirements {
task {
id
}
}
}
}
"""
def fetch_quests():
print("Fetching quests from Tarkov.dev...")
response = requests.post(API_URL, json={"query": QUERY})
response.raise_for_status()
result = response.json()
if "errors" in result:
print("GraphQL Errors:")
print(result["errors"])
raise Exception("GraphQL query failed.")
return result["data"]["tasks"]
def setup_db(conn):
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS quests (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
trader TEXT NOT NULL,
wiki_link TEXT
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS quest_deps (
quest_id TEXT NOT NULL,
depends_on TEXT NOT NULL,
PRIMARY KEY (quest_id, depends_on)
)
""")
conn.commit()
def store_quests(conn, quests):
cur = conn.cursor()
for q in quests:
cur.execute(
"INSERT OR REPLACE INTO quests VALUES (?, ?, ?, ?)",
(q["id"], q["name"], q["trader"]["name"], q.get("wikiLink"))
)
for req in q.get("taskRequirements") or []:
cur.execute(
"INSERT OR IGNORE INTO quest_deps VALUES (?, ?)",
(q["id"], req["task"]["id"])
)
conn.commit()
def get_collector_id(conn):
cur = conn.cursor()
cur.execute("SELECT id FROM quests WHERE name = 'Collector'")
row = cur.fetchone()
if not row:
raise Exception("Collector quest not found in DB.")
return row[0]
def get_all_prereqs(conn, quest_id, seen=None):
if seen is None:
seen = set()
cur = conn.cursor()
cur.execute(
"SELECT depends_on FROM quest_deps WHERE quest_id = ?",
(quest_id,)
)
deps = [r[0] for r in cur.fetchall()]
for dep in deps:
if dep not in seen:
seen.add(dep)
get_all_prereqs(conn, dep, seen)
return seen
def main():
quests = fetch_quests()
conn = sqlite3.connect(DB_PATH)
setup_db(conn)
store_quests(conn, quests)
collector_id = get_collector_id(conn)
prereqs = get_all_prereqs(conn, collector_id)
print("\n=== Quests Required for Collector ===\n")
if prereqs:
cur = conn.cursor()
placeholders = ",".join("?" * len(prereqs))
cur.execute(
f"""
SELECT name, trader, wiki_link
FROM quests
WHERE id IN ({placeholders})
ORDER BY trader, name
""",
list(prereqs)
)
rows = cur.fetchall()
for name, trader, _ in rows:
print(f"[ ] {trader}: {name}")
print(f"\nTotal required quests: {len(rows)}")
else:
print("Collector has no prerequisites (unexpected).")
conn.close()
if __name__ == "__main__":
main()

BIN
tarkov.db

Binary file not shown.

143
templates/collector.html Normal file
View File

@@ -0,0 +1,143 @@
<!doctype html>
<html>
<head>
<title>OnlyScavs Collector Checklist</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg: #121212;
--panel: #1a1a1a;
--text: #eee;
--muted: #bbb;
--border: #333;
--accent: #9ccfff;
--done-bg: #1a2a1a;
--done-text: #6ec96e;
}
body {
font-family: sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
padding: 16px;
}
.page {
max-width: 780px;
margin: 0 auto;
}
h1 { margin-bottom: 4px; }
.subtitle {
color: var(--muted);
margin: 0 0 16px;
font-size: 0.95rem;
}
.progress-bar-wrap {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 999px;
height: 10px;
margin-bottom: 20px;
overflow: hidden;
}
.progress-bar-fill {
background: var(--done-text);
height: 100%;
border-radius: 999px;
transition: width 0.3s;
}
.trader-group { margin-bottom: 8px; }
.trader-header {
font-size: 0.8rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
padding: 12px 0 4px;
border-bottom: 1px solid var(--border);
margin-bottom: 4px;
}
.quest-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 4px;
border-bottom: 1px solid #222;
border-radius: 4px;
}
.quest-row.done {
background: var(--done-bg);
}
.quest-row.done .quest-name {
text-decoration: line-through;
color: var(--done-text);
}
.quest-name {
flex: 1;
font-size: 0.95rem;
}
.quest-name a {
color: var(--accent);
font-size: 0.8rem;
margin-left: 6px;
}
.toggle-btn {
background: #2a2a2a;
color: var(--text);
border: 1px solid #444;
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
}
.quest-row.done .toggle-btn {
background: #1e3a1e;
border-color: #3a6a3a;
color: var(--done-text);
}
nav { margin-bottom: 20px; }
nav a { color: var(--accent); font-size: 0.9rem; }
</style>
</head>
<body>
<div class="page">
<nav><a href="/">← Back to Keys</a></nav>
<h1>Collector Checklist</h1>
<p class="subtitle">
{{ done }} / {{ total }} quests completed
</p>
<div class="progress-bar-wrap">
<div class="progress-bar-fill" style="width: {{ (done / total * 100) | round(1) if total else 0 }}%"></div>
</div>
{% set ns = namespace(current_trader=None) %}
{% for quest in quests %}
{% if quest.trader != ns.current_trader %}
{% if ns.current_trader is not none %}</div>{% endif %}
<div class="trader-group">
<div class="trader-header">{{ quest.trader }}</div>
{% set ns.current_trader = quest.trader %}
{% endif %}
<form method="post" action="/collector/toggle" style="margin:0">
<input type="hidden" name="quest_id" value="{{ quest.id }}">
<input type="hidden" name="done" value="{{ '0' if quest.done else '1' }}">
<div class="quest-row {% if quest.done %}done{% endif %}">
<span class="quest-name">
{{ quest.name }}
{% if quest.wiki_link %}
<a href="{{ quest.wiki_link }}" target="_blank">wiki</a>
{% endif %}
</span>
<button class="toggle-btn" type="submit">
{{ '✓ Done' if quest.done else 'Mark done' }}
</button>
</div>
</form>
{% endfor %}
{% if ns.current_trader is not none %}</div>{% endif %}
</div>
</body>
</html>

View File

@@ -168,6 +168,7 @@
<body>
<div class="page">
<nav style="margin-bottom:12px"><a href="/collector">Collector Checklist →</a></nav>
<h1>OnlyScavs Keys</h1>
<form method="get" class="filters">