MGS2 Master Collection 100% companion
A single-file companion page for 100%-ing Metal Gear Solid 2: Sons of Liberty (Master Collection Version), plus an optional local helper for live Steam sync. What's in it: - Roadmap: the 5-pass plan plus a 48-step ordered walkthrough of the Tanker and Plant, with per-step sub-checklists for dog tags, achievements and items - Achievements: all 51 with official Steam icons, missable/cumulative flags, filters, search and a full how-to for each - Dog Tags: all 394 across 10 difficulty lists, with 81 conditional-spawn notes - Area Maps: 10 guard-position maps rebuilt cell-by-cell from the community tracking spreadsheet, zoomable, with a label-size control - Photos: 48 location screenshots pinned to the step where each thing is, with cross-source duplicates removed by perceptual hash - Big Boss Run, Missables, VR/Extra Modes, Sources & Notes - Steam sync: ticks what you already own from any public profile - 10 dark colour themes, contrast-checked The page is one self-contained HTML file with no build step or dependencies. Compiled from community guides (Steam Community, Video Chums, u/Spikeyroxas, Dayngl's Guides, TrueSteamAchievements) - see README for full credits.
This commit is contained in:
commit
f558eb439e
6 changed files with 2109 additions and 0 deletions
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# progress exported from the page's Export button
|
||||
mgs2-progress.json
|
||||
mgs2-progress*.json
|
||||
|
||||
# python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# os / editor cruft
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# logs
|
||||
*.log
|
||||
30
LICENSE
Normal file
30
LICENSE
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 rain
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
This licence covers the CODE in this repository only (index.html, steam-sync.py,
|
||||
steam-proxy-worker.js). The guide content is compiled from community sources and
|
||||
the screenshots are hot-linked from their authors' sites — see the Licence
|
||||
section of README.md before reusing either.
|
||||
|
||||
Metal Gear Solid 2: Sons of Liberty is (c) Konami. This is an unofficial,
|
||||
non-commercial fan project.
|
||||
139
README.md
Normal file
139
README.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# la-li-lu-le-lo
|
||||
|
||||
> *The Patriots have restricted access to this information.*
|
||||
|
||||
A single-file, offline-friendly companion page for 100%-ing **Metal Gear Solid 2: Sons of Liberty — Master Collection Version** (51 achievements).
|
||||
|
||||
## Files
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `index.html` | The whole guide. Self-contained — data, styles, logic and your progress all live in this one file. |
|
||||
| `steam-sync.py` | Optional local helper. Serves this folder **and** proxies Steam achievement lookups so the page can auto-tick what you already own. |
|
||||
| `steam-proxy-worker.js` | Optional Cloudflare Worker. Same job as the Python helper, but for when the page is **hosted** somewhere instead of run locally. |
|
||||
|
||||
## Hosting it (GitHub Pages, Netlify, Cloudflare Pages, Neocities…)
|
||||
|
||||
**The page itself hosts fine as-is.** It is one static HTML file with all the guide data, the 10 area maps and the colour coding baked in, and it stores progress in the browser's `localStorage`. Drop it in a repo, enable Pages, done. No build step, no dependencies.
|
||||
|
||||
Two things behave differently on a static host:
|
||||
|
||||
**1. Live Steam sync needs a server.** Steam's achievement page sends no CORS headers, so a browser cannot read it from another origin — that is the whole reason `steam-sync.py` exists. Hosted with no proxy, the page says so instead of failing silently, and everything except sync still works. To get sync back you have two options:
|
||||
|
||||
- **Run it locally** (`python3 steam-sync.py`) — simplest, and what the sync feature was built for.
|
||||
- **Deploy the Worker** — paste `steam-proxy-worker.js` into a free Cloudflare Worker, then set at the top of `index.html`:
|
||||
|
||||
```js
|
||||
const STEAM_PROXY = 'https://your-worker-name.yourname.workers.dev';
|
||||
```
|
||||
|
||||
Any serverless function works the same way — it just has to forward `/api/steam?profile=…` to Steam and return JSON with a permissive `Access-Control-Allow-Origin`.
|
||||
|
||||
**2. Your ticks do not follow you between addresses.** `localStorage` is scoped per origin, so `http://127.0.0.1:8765/` and `https://you.github.io/mgs2/` are separate checklists. Use **Export** on one and **Import** on the other — that is exactly what those buttons are for.
|
||||
|
||||
There is also a deliberate default in the page: `DEFAULT_PROFILE` is pre-filled so it syncs on load. Set it to `''` before publishing if you would rather it started blank, and change `ALLOW_ORIGIN` in the Worker from `*` to your site so nobody else can point their own page at your function.
|
||||
|
||||
### A note on the images
|
||||
|
||||
The 62 location screenshots are **hot-linked** from the Steam Community CDN and Dayngl's Guides, with `referrerpolicy="no-referrer"` so they load from any domain. That keeps the page small and credits the original authors, but it does mean the page depends on two external hosts. If you plan to publish it widely rather than keep it for yourself, download the images into an `img/` folder and rewrite the URLs — that also removes any question about borrowing someone else's bandwidth.
|
||||
|
||||
## Run it locally (recommended)
|
||||
|
||||
```bash
|
||||
cd ~/Desktop/la-li-lu-le-lo
|
||||
python3 steam-sync.py
|
||||
```
|
||||
|
||||
Then open **http://127.0.0.1:8765/** and leave it on your second screen.
|
||||
|
||||
`python3 steam-sync.py --open` launches the browser for you. `PORT=9000 python3 steam-sync.py` uses a different port.
|
||||
|
||||
No dependencies, no Steam API key, no install. Requires Python 3 (already on macOS/Linux; on Windows use `py steam-sync.py`).
|
||||
|
||||
**Keep it running in the background** (so it survives closing the terminal):
|
||||
|
||||
```bash
|
||||
nohup python3 steam-sync.py > /tmp/steam-sync.log 2>&1 &
|
||||
```
|
||||
|
||||
To stop it later: `pkill -f steam-sync.py`
|
||||
If the port is already in use, either it is already running or you can pick another: `PORT=9000 python3 steam-sync.py`.
|
||||
|
||||
## Just want the checklist, no sync?
|
||||
|
||||
Double-click `index.html`. Everything works except live Steam sync. Note that opening it as a `file://` page makes browser storage less reliable — use **Export** to keep a backup of your ticks.
|
||||
|
||||
## Steam sync
|
||||
|
||||
- Enter your **vanity name** (e.g. `mrmelonbread`) or **SteamID64** on the **🔄 Steam Sync** tab and hit **Sync now**.
|
||||
- It re-syncs automatically every time the page loads.
|
||||
- Achievements you already own get a `STEAM ✓` badge and a blue edge, and are counted as done.
|
||||
- Use the **⬇ Missing on Steam** filter on the Achievements tab to see only what is left.
|
||||
- Your profile is pre-filled. Clear it with **Forget profile**.
|
||||
|
||||
**If it fails,** the page tells you why. The usual cause is a private profile:
|
||||
Steam → your profile → **Edit Profile → Privacy Settings → Game details → Public**.
|
||||
|
||||
## What's inside
|
||||
|
||||
- **🗺️ Roadmap** — the 5-pass plan, plus a 48-step ordered walkthrough of the Tanker and Plant with every collectible, box, weapon and one-shot achievement flagged in the order you hit them.
|
||||
- Every step has **sub-checkboxes** underneath it listing exactly what to get there — the dog tags by name and area, the achievements, and the items.
|
||||
- **The dog-tag boxes are the same checklist as the Dog Tags tab.** Tick a tag in the walkthrough and it ticks in the Dog Tags tab, and vice versa — same key, same saved state. Achievements and items sync to their own tabs the same way.
|
||||
- A **difficulty selector** at the top picks which tag list the steps show (defaults to Very Easy, for run 1). Coverage is complete: all **67/70/82/87/88** tags for Very Easy/Easy/Normal/Hard/Extreme appear exactly once.
|
||||
- Text is **colour coded** so you can scan a step at a glance: <span>teal = guard whose dog tag it is</span>, violet = weapon/box, blue = achievement, **amber = missable achievement**, red = boss.
|
||||
- **🏆 Achievements** — all 51 with official Steam icons, missable/cumulative flags, category filters, search, and a full how-to for each.
|
||||
- **🏷️ Dog Tags** — all **394** tags across 10 difficulty lists, with guard names, areas, and 81 conditional/missable spawn notes.
|
||||
- **🗺️ Area Maps** — the guard-position maps from the tracking spreadsheet, **rebuilt cell-by-cell** as real images (no internet needed). Colour-coded: orange = guard with a tag, magenta = tough guard, red = boss with a tag, green = conditional guard. Zoomable, with the spreadsheet's own Keys and numbered guard legend rendered in place, plus a **label-size control**. Reachable from the Dog Tags tab via **Open … area map**.
|
||||
- **📷 Location photos** — **48 in-game screenshots** of the exact spots (locker poster, fire extinguisher, Hold No. 2 screens, the Book, the orange boxes, and more), drawn from **two** guides: the Steam Community guide and Dayngl's Guides. Each one is pinned to the **specific step where that thing is** — the seven Moving Day boxes are spread across the six steps that actually contain them, and the two Johnny on the Spot microphone moments go to their own steps. Hot-linked at thumbnail size.
|
||||
- Duplicates are removed: Dayngl's guide reuses the same screenshots as the Steam guide, so 22 pairs were detected by perceptual hash (dHash, ≤14/240 bits apart) and de-duplicated — you will not see the same picture twice.
|
||||
- **Click any photo to zoom it** in a full-screen viewer. Then: **click the image** (or press Space) to toggle fit ↔ full resolution, **drag** to pan while zoomed, **← / →** to move between the photos in that step, **Esc** or a backdrop click to close, and **open original ↗** to jump to the source site.
|
||||
- **🔄 Steam Sync** — see above.
|
||||
- **💀 Big Boss Run** — every codename requirement, the heal points, time checkpoints, save points, and a strategy for each of the 8 bosses.
|
||||
- **⚠️ Missables** — everything that can be permanently missed, in story order.
|
||||
- **🕹️ VR / Extra Modes** — Snake Tales, Boss Survival, VR missions, Casting Theater, plus the special-item unlock table.
|
||||
- **📚 Sources & Notes** — credits, links, and Master Collection version differences.
|
||||
|
||||
## Colour themes
|
||||
|
||||
The **theme dropdown in the header** switches the whole page. It is remembered in `localStorage` and applied before first paint, so there is no flash on reload. All of them are dark.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `Codec` | the default teal/blue |
|
||||
| `Gruvbox Dark` · `Monokai` · `Dracula` · `Nord` | |
|
||||
| `Solarized Dark` | |
|
||||
| `Tokyo Night` · `Catppuccin Mocha` · `One Dark` · `Rosé Pine` | |
|
||||
|
||||
Everything is themed through CSS custom properties — panels, borders, the walkthrough colour code, pills, callouts, the lightbox and the progress bars. The **area maps keep their white background** on purpose: they are reproductions of a spreadsheet, and inverting them would make the colour legend lie.
|
||||
|
||||
Every foreground/background pair in all 10 themes was checked against WCAG contrast; the worst case is **3.1:1** (Nord's red), which is above the 3:1 threshold for UI text and components.
|
||||
|
||||
## Progress & backups
|
||||
|
||||
Ticks save automatically to this browser's `localStorage`, under the key `mgs2mc-guide-v1`.
|
||||
They are per-browser **and** per-origin — if you switch browser or open the file from a different path you'll get a fresh checklist.
|
||||
|
||||
Use **Export** to download `mgs2-progress.json` and **Import** to restore it. Export before each playthrough.
|
||||
|
||||
## Credits
|
||||
|
||||
Compiled from community work — full links are on the **Sources & Notes** tab:
|
||||
|
||||
- Steam Community guide by **Cole ヴ Viper** (achievement write-ups, boss strategies, Big Boss criteria)
|
||||
- **Video Chums** dog tag checklist (all 394 tags)
|
||||
- **u/Spikeyroxas** dog tag spreadsheet (conditional spawn notes)
|
||||
- **Dayngl's Guides** (cross-check)
|
||||
- **TrueSteamAchievements** (authoritative 51-achievement list)
|
||||
|
||||
Unofficial fan-made companion, for personal use. MGS2 © Konami.
|
||||
|
||||
## Licence
|
||||
|
||||
The **code** here (`index.html`, `steam-sync.py`, `steam-proxy-worker.js`) is **MIT** — see [LICENSE](LICENSE).
|
||||
|
||||
Two carve-outs worth knowing before you fork it:
|
||||
|
||||
- **The guide content is compiled from other people's work.** The achievement write-ups, the 394 dog tags, the guard-position maps and the Big Boss strategies all come from the community guides credited above. This repo reorganises and presents them; it does not claim authorship. If you reuse the text, credit the original authors.
|
||||
- **The screenshots are not in this repo, and are not mine.** All 48 are hot-linked from the Steam Community CDN and Dayngl's Guides and remain the property of their authors. If you fork this and republish it widely, download them into an `img/` folder or drop them — see the note under *Hosting it*.
|
||||
|
||||
Metal Gear Solid 2: Sons of Liberty is © Konami. This is an unofficial, non-commercial fan project.
|
||||
1587
index.html
Normal file
1587
index.html
Normal file
File diff suppressed because one or more lines are too long
138
steam-proxy-worker.js
Normal file
138
steam-proxy-worker.js
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/**
|
||||
* steam-proxy-worker.js — Cloudflare Worker that gives a *hosted* copy of
|
||||
* la-li-lu-le-lo (the MGS2 Master Collection 100% companion) working Steam sync.
|
||||
*
|
||||
* WHY THIS EXISTS
|
||||
* ---------------
|
||||
* The companion reads a public Steam Community page to find out which achievements an
|
||||
* account already has. That page is served as XML and sends no CORS headers, so a browser
|
||||
* cannot fetch it from another origin. Running steam-sync.py locally solves this on your own
|
||||
* machine; this Worker solves it once the page is hosted on GitHub Pages / Netlify / Vercel /
|
||||
* Cloudflare Pages.
|
||||
*
|
||||
* DEPLOY (free tier is plenty — this is a handful of requests per page load)
|
||||
* -------------------------------------------------------------------------
|
||||
* 1. https://dash.cloudflare.com -> Workers & Pages -> Create -> Worker
|
||||
* 2. Paste this whole file in, replacing the starter code. Deploy.
|
||||
* 3. Copy the worker URL (e.g. https://mgs2-steam-proxy.YOURNAME.workers.dev)
|
||||
* 4. In index.html, set: const STEAM_PROXY = 'https://mgs2-steam-proxy.YOURNAME.workers.dev';
|
||||
* 5. Done — the page will use it automatically when it is not running on localhost.
|
||||
*
|
||||
* Optional: change ALLOW_ORIGIN below from '*' to your page's origin to stop anyone else
|
||||
* pointing their own page at your Worker.
|
||||
*
|
||||
* The Worker makes exactly one outbound request, to the public Steam Community page for the
|
||||
* profile it is asked about. It stores nothing and logs nothing.
|
||||
*/
|
||||
|
||||
const APPID = '2131640';
|
||||
const ALLOW_ORIGIN = '*'; // e.g. 'https://yourname.github.io'
|
||||
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
|
||||
|
||||
function cors(res) {
|
||||
res.headers.set('Access-Control-Allow-Origin', ALLOW_ORIGIN);
|
||||
res.headers.set('Access-Control-Allow-Headers', '*');
|
||||
res.headers.set('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
res.headers.set('Cache-Control', 'no-store');
|
||||
return res;
|
||||
}
|
||||
function json(obj) {
|
||||
return new Response(JSON.stringify(obj), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
function cdata(block, tag) {
|
||||
const m = block.match(new RegExp('<' + tag + '><!\\[CDATA\\[([\\s\\S]*?)\\]\\]></' + tag + '>'));
|
||||
return m ? m[1].trim() : '';
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method === 'OPTIONS') return cors(new Response(null, { status: 204 }));
|
||||
if (url.pathname.replace(/\/$/, '') !== '/api/steam') {
|
||||
return cors(new Response('Not found — this Worker only serves /api/steam', { status: 404 }));
|
||||
}
|
||||
|
||||
const profile = (url.searchParams.get('profile') || '').trim();
|
||||
if (!profile) {
|
||||
return cors(json({ ok: false, error: 'No profile given.', hint: 'Enter a vanity name or SteamID64.' }));
|
||||
}
|
||||
|
||||
const target = /^\d{17}$/.test(profile)
|
||||
? 'https://steamcommunity.com/profiles/' + profile + '/stats/' + APPID + '/?tab=achievements&xml=1'
|
||||
: 'https://steamcommunity.com/id/' + encodeURIComponent(profile) + '/stats/' + APPID + '/?tab=achievements&xml=1';
|
||||
|
||||
let xml;
|
||||
try {
|
||||
const r = await fetch(target, { headers: { 'User-Agent': UA, 'Accept-Language': 'en' } });
|
||||
xml = await r.text();
|
||||
} catch (e) {
|
||||
return cors(json({ ok: false, error: 'Could not reach Steam.', hint: 'Try again in a moment.' }));
|
||||
}
|
||||
|
||||
const err = xml.match(/<error><!\[CDATA\[([\s\S]*?)\]\]><\/error>/);
|
||||
if (err) {
|
||||
return cors(json({
|
||||
ok: false,
|
||||
error: err[1].trim(),
|
||||
hint: 'Check the spelling, or paste the whole profile URL.',
|
||||
}));
|
||||
}
|
||||
const privacy = (xml.match(/<privacyState>(.*?)<\/privacyState>/) || [, ''])[1].trim();
|
||||
if (privacy && privacy !== 'public') {
|
||||
return cors(json({
|
||||
ok: false,
|
||||
error: 'That profile is ' + privacy + '.',
|
||||
hint: "Steam > Edit Profile > Privacy Settings > set 'Game details' to Public, then sync again.",
|
||||
}));
|
||||
}
|
||||
|
||||
const achievements = [];
|
||||
const re = /<achievement closed="(\d)">([\s\S]*?)<\/achievement>/g;
|
||||
let m;
|
||||
while ((m = re.exec(xml)) !== null) {
|
||||
const body = m[2];
|
||||
achievements.push({
|
||||
api: cdata(body, 'apiname'),
|
||||
name: cdata(body, 'name'),
|
||||
desc: cdata(body, 'description'),
|
||||
unlocked: m[1] === '1',
|
||||
icon: cdata(body, 'iconOpen'),
|
||||
iconLocked: cdata(body, 'iconClosed'),
|
||||
});
|
||||
}
|
||||
|
||||
if (!achievements.length) {
|
||||
return cors(json({
|
||||
ok: false,
|
||||
error: 'No MGS2 Master Collection stats on that account.',
|
||||
hint: 'The account must own the game (appid ' + APPID + ') and have launched it at least once.',
|
||||
}));
|
||||
}
|
||||
|
||||
const id64 = (xml.match(/<steamID64>(\d+)<\/steamID64>/) || [, ''])[1];
|
||||
// This XML carries no persona name, only steamID64 and customURL, so fall back sensibly.
|
||||
let persona = '';
|
||||
const pm = xml.match(/<steamID>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/steamID>/);
|
||||
if (pm && pm[1].trim()) persona = pm[1].trim();
|
||||
if (!persona) {
|
||||
const cm = xml.match(/<customURL><!\[CDATA\[(.*?)\]\]><\/customURL>/);
|
||||
if (cm && cm[1].trim()) persona = cm[1].trim();
|
||||
}
|
||||
if (!persona) persona = profile;
|
||||
|
||||
return cors(json({
|
||||
ok: true,
|
||||
game: 'METAL GEAR SOLID 2: Sons of Liberty - Master Collection Version',
|
||||
appid: APPID,
|
||||
steamid64: id64,
|
||||
persona: persona,
|
||||
unlockedCount: achievements.filter((a) => a.unlocked).length,
|
||||
total: achievements.length,
|
||||
achievements: achievements,
|
||||
}));
|
||||
},
|
||||
};
|
||||
199
steam-sync.py
Normal file
199
steam-sync.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
steam-sync.py — tiny local helper for la-li-lu-le-lo, the MGS2 Master Collection
|
||||
100% companion.
|
||||
|
||||
It does two things:
|
||||
1. serves the folder it lives in over http://127.0.0.1:8765/ (so the page gets a
|
||||
real origin and localStorage saves reliably)
|
||||
2. exposes /api/steam?profile=<vanity|steamid64> which fetches that profile's
|
||||
public MGS2 Master Collection achievements from Steam and returns them as JSON
|
||||
|
||||
No API key. No third-party packages. Only one outbound request, to the public
|
||||
Steam Community page for the profile you ask for.
|
||||
|
||||
python3 steam-sync.py # then open http://127.0.0.1:8765/
|
||||
python3 steam-sync.py --open # ...and open the browser for you
|
||||
PORT=9000 python3 steam-sync.py # different port
|
||||
"""
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import socketserver
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import webbrowser
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
APPID = "2131640"
|
||||
GAME = "METAL GEAR SOLID 2: Sons of Liberty - Master Collection Version"
|
||||
PORT = int(os.environ.get("PORT", "8765"))
|
||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0 Safari/537.36")
|
||||
|
||||
|
||||
def steam_url(profile):
|
||||
profile = profile.strip()
|
||||
if re.fullmatch(r"\d{17}", profile):
|
||||
return "https://steamcommunity.com/profiles/%s/stats/%s/?tab=achievements&xml=1" % (profile, APPID)
|
||||
return "https://steamcommunity.com/id/%s/stats/%s/?tab=achievements&xml=1" % (
|
||||
urllib.parse.quote(profile), APPID)
|
||||
|
||||
|
||||
def fetch_steam(profile):
|
||||
url = steam_url(profile)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept-Language": "en"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=25) as r:
|
||||
raw = r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"ok": False, "error": "Steam returned HTTP %s." % e.code,
|
||||
"hint": "Try again in a moment; Steam rate-limits rapid requests."}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": "Could not reach Steam: %s" % e,
|
||||
"hint": "Check your internet connection."}
|
||||
|
||||
try:
|
||||
root = ET.fromstring(raw)
|
||||
except ET.ParseError:
|
||||
return {"ok": False, "error": "Steam sent something unreadable.",
|
||||
"hint": "The profile may be private or Steam may be rate-limiting."}
|
||||
|
||||
err = root.find("error")
|
||||
if err is not None and (err.text or "").strip():
|
||||
msg = err.text.strip()
|
||||
hint = "Check the spelling, or paste the whole profile URL."
|
||||
if "private" in msg.lower():
|
||||
hint = "Steam > your profile > Edit Profile > Privacy Settings > set 'Game details' to Public."
|
||||
return {"ok": False, "error": msg, "hint": hint}
|
||||
|
||||
privacy = (root.findtext("privacyState") or "").strip()
|
||||
if privacy and privacy != "public":
|
||||
return {"ok": False,
|
||||
"error": "That profile is %s." % privacy,
|
||||
"hint": "Steam > Edit Profile > Privacy Settings > set 'Game details' to Public, then sync again."}
|
||||
|
||||
ach_nodes = root.findall("./achievements/achievement")
|
||||
if not ach_nodes:
|
||||
return {"ok": False,
|
||||
"error": "No MGS2 Master Collection stats on that account.",
|
||||
"hint": "The account must own the game (appid %s) and have launched it at least once." % APPID}
|
||||
|
||||
out = []
|
||||
for a in ach_nodes:
|
||||
out.append({
|
||||
"api": (a.findtext("apiname") or "").strip(),
|
||||
"name": (a.findtext("name") or "").strip(),
|
||||
"desc": (a.findtext("description") or "").strip(),
|
||||
"unlocked": a.get("closed") == "1",
|
||||
"icon": (a.findtext("iconOpen") or "").strip(),
|
||||
"iconLocked": (a.findtext("iconClosed") or "").strip(),
|
||||
})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"game": GAME,
|
||||
"appid": APPID,
|
||||
"steamid64": (root.findtext("./player/steamID64") or "").strip(),
|
||||
# This XML carries no persona name, only steamID64 and customURL.
|
||||
"persona": ((root.findtext("./player/steamID")
|
||||
or root.findtext("./player/customURL")
|
||||
or profile) or "").strip(),
|
||||
"unlockedCount": sum(1 for a in out if a["unlocked"]),
|
||||
"total": len(out),
|
||||
"achievements": out,
|
||||
}
|
||||
|
||||
|
||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kw):
|
||||
super().__init__(*args, directory=ROOT, **kw)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
# Never let a closed/dead output pipe (e.g. when run in the background)
|
||||
# take the whole server down with it.
|
||||
if "/api/" not in (self.path or ""):
|
||||
return
|
||||
try:
|
||||
sys.stderr.write(" steam-sync: %s %s\n" % (self.command, self.path.split("?")[0]))
|
||||
sys.stderr.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _cors(self):
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Headers", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self._cors()
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.split("?")[0] != "/api/steam":
|
||||
return super().do_GET()
|
||||
|
||||
qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
||||
profile = (qs.get("profile") or [""])[0].strip()
|
||||
if not profile:
|
||||
payload = {"ok": False, "error": "No profile given.",
|
||||
"hint": "Enter a vanity name or SteamID64."}
|
||||
else:
|
||||
try:
|
||||
payload = fetch_steam(profile)
|
||||
except Exception as e: # never 500 on the user
|
||||
payload = {"ok": False, "error": "Unexpected error: %s" % e, "hint": ""}
|
||||
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self._cors()
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
class Server(socketserver.ThreadingTCPServer):
|
||||
allow_reuse_address = True
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
def main():
|
||||
# A stalled connection to Steam can never hang a handler thread forever.
|
||||
socket.setdefaulttimeout(30)
|
||||
if not os.path.exists(os.path.join(ROOT, "index.html")):
|
||||
print("!! index.html is not in %s — keep steam-sync.py next to it." % ROOT)
|
||||
try:
|
||||
httpd = Server(("127.0.0.1", PORT), Handler)
|
||||
except OSError as e:
|
||||
print("!! Could not bind port %d: %s" % (PORT, e))
|
||||
print(" Another copy may already be running, or set PORT=<other> and retry.")
|
||||
return 1
|
||||
url = "http://127.0.0.1:%d/" % PORT
|
||||
print("=" * 66)
|
||||
print(" la-li-lu-le-lo — MGS2 100% companion (local helper)")
|
||||
print("=" * 66)
|
||||
print(" serving : %s" % ROOT)
|
||||
print(" page : %s" % url)
|
||||
print(" api : %sapi/steam?profile=<vanity or SteamID64>" % url)
|
||||
print(" stop : Ctrl+C")
|
||||
print("-" * 66)
|
||||
if "--open" in sys.argv:
|
||||
webbrowser.open(url)
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\n stopped.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue