Initial commit

This commit is contained in:
Sean Betts committed 2026-04-26 10:26:58 +01:00
commit fbb8c3da72
24 files changed
+2476

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
.DS_Store
__pycache__/
*.pyc
.venv/
node_modules/
# Local agent/browser state
.local/
profiles/
playwright-profile/
auth/
*.state.json
*.storage.json
# Ad hoc run output if someone chooses to place it inside the repo
artifacts/
output/
runs/
tmp/
scratch/
# Playwright and browser leftovers
.playwright/
playwright-report/
# Media and extracted frames kept outside normal repo state
frames/
downloads/
+304
View File
@@ -0,0 +1,304 @@
# Steam Hardware Watch
Agent-oriented monitoring workflow for rumored or upcoming Valve hardware, focused on:
- `Steam Controller`
- `Steam Machine`
- `Steam Frame`
It is designed to be run by a coding agent through the included `SKILL.md`, with scripts that:
- check `Komodo`, `SteamDB`, `SteamTracking`, and Valve endpoints
- save raw artifacts for each run
- compare against the previous run
- draft a human-readable status update
- track visual assets as `discovered`, `retrieved`, or `blocked`
## What This Is
This repo is not a generic scraper.
It is a repeatable evidence-gathering workflow for answering a narrow set of questions:
- did anything change?
- is there a price leak?
- is there a release-date leak?
- does the controller look ahead of Machine/Frame?
- are there new media assets or support documents?
The normal entry point is the wrapper script:
```sh
scripts/run_watch.sh YYYY-MM-DD /path/to/run-output /path/to/SteamTracking
```
## Repository Layout
```text
steam-hardware-watch/
├── SKILL.md
├── README.md
├── .gitignore
├── agents/
├── references/
├── scripts/
└── status/
```
Important directories:
- `scripts/`: the runnable checks and report builders
- `references/`: source map, Komodo notes, evidence rubric
- `status/`: seeded baseline answer and run notes
## Dependencies
Required:
- `bash` or `sh`
- `curl`
- `jq`
- `python3`
- `node`
- `rg`
Recommended:
- `playwright-cli`
- Google Chrome or Chromium available to Playwright
- macOS `open` command with Google Chrome installed for the Komodo background bootstrap helper
Optional:
- `ffmpeg` for frame extraction or manual media inspection
## Quick Start
1. Clone the repo.
2. Make the scripts executable if needed:
```sh
chmod +x scripts/*.sh
```
3. Ensure `SteamTracking` is available locally, for example at:
```text
/tmp/SteamTracking-master
```
4. Run a watch pass:
```sh
scripts/run_watch.sh 2026-04-25 ~/steam_hardware_watch /tmp/SteamTracking-master
```
This creates a dated run folder under `~/steam_hardware_watch/2026-04-25`.
## Output
Each run writes:
- raw API and HTML snapshots
- per-source summary files
- comparison report versus the previous run when available
- `run-summary.md`
- `status-draft.md`
- visual asset ledgers:
- `discovered-visual-assets.tsv`
- `retrieved-visual-assets.tsv`
- `blocked-visual-assets.tsv`
- `manual-asset-urls.txt`
The main human-readable file is:
- `RUN_DIR/reports/run-summary.md`
## Agent Usage
This repo is meant to be used by coding agents, not only by a human at a shell.
### Codex
Install or symlink the repo into your Codex skills directory:
```sh
ln -s "$PWD" ~/.codex/skills/steam-hardware-watch
```
Then ask Codex to use the `steam-hardware-watch` skill.
### Claude or Other Agents
Point the agent at this repo and have it:
1. read `SKILL.md`
2. use `scripts/run_watch.sh` as the default entry point
3. summarize results from:
- `run-summary.md`
- `status-draft.md`
- any `compare-vs-*.md`
The agent should treat the scripts as implementation details and present the run summaries to the user.
## Komodo Caveat
`Komodo` is the most valuable source and the least reliable one.
What usually works:
- public `wp-json` product, section, and media endpoints
- still-image asset URLs
What may fail:
- video and AVIF asset downloads
- some API fetches when Cloudflare blocks automation
- optional Playwright fallback if it is pointed at a problematic local browser channel
The workflow handles this by separating:
- `discovered`
- `retrieved`
- `blocked`
Blocked assets are still reported with direct manual URLs.
## Optional Komodo Browser Bootstrap
Use this only when Komodo blocks automated media access and you need the protected files.
This is intentionally user-local and should never be committed.
### Preferred Low-Disruption Flow
Use the bundled helper to launch a dedicated Komodo Chrome instance in the background:
```sh
scripts/bootstrap_komodo.sh
```
This:
- uses a dedicated profile under `.local/`
- launches a separate Chrome instance in the background
- exposes a stable CDP endpoint
- writes `.local/komodo-env.sh`
If Komodo needs any manual interaction, switch to that dedicated window when convenient. Otherwise leave it running in the background.
Then run:
```sh
scripts/run_watch.sh 2026-04-25 ~/steam_hardware_watch /tmp/SteamTracking-master
```
`check_komodo.sh` will automatically pick up `.local/komodo-env.sh` when present.
`run_watch.sh` will also auto-close the dedicated Komodo browser at the end unless you set:
```sh
KOMODO_KEEP_BROWSER_OPEN=1
```
When finished:
```sh
scripts/close_komodo.sh
```
That closes only the dedicated Komodo browser/profile, not your normal run output.
### Manual Playwright Bootstrap
If you prefer the raw Playwright flow, you can still do it manually.
1. Open a persistent Playwright browser profile:
```sh
playwright-cli open --persistent --profile ~/.steam-hardware-watch/playwright-profile https://komodostation.com/product/steam-controller_jpy/
```
2. Solve any challenge manually in that browser.
3. Optionally save storage state:
```sh
playwright-cli state-save ~/.steam-hardware-watch/komodo-state.json
```
4. Reuse that state for later agent runs with environment variables:
```sh
PLAYWRIGHT_PROFILE_DIR=~/.steam-hardware-watch/playwright-profile
PLAYWRIGHT_STORAGE_STATE=~/.steam-hardware-watch/komodo-state.json
PLAYWRIGHT_REFERER=https://komodostation.com/product/steam-controller_jpy/
KOMODO_PLAYWRIGHT_FALLBACK=1
```
The current helper scripts support these variables for Playwright-based fallback fetches.
Important:
- the Komodo Playwright fallback is `off` by default
- it only runs when `KOMODO_PLAYWRIGHT_FALLBACK=1` is set
- by default the Playwright helpers use Playwright's normal Chromium target, not your system Chrome
- only set `PLAYWRIGHT_BROWSER_CHANNEL=chrome` if you explicitly want that behavior
### Stronger Fallback: Live Trusted Browser Attach
If storage state is not enough for Komodo, use a live trusted browser session instead.
This is the most reliable path we found for protected Komodo media.
1. Open the persistent profile and load Komodo successfully:
```sh
playwright-cli open --headed --persistent --profile ~/.steam-hardware-watch/playwright-profile https://komodostation.com/product/steam-controller_jpy/
```
2. Leave that browser running.
3. Discover the local CDP port for that profile:
```sh
ps aux | rg 'playwright-profile|remote-debugging-port'
```
4. Set:
```sh
PLAYWRIGHT_CDP_ENDPOINT=http://127.0.0.1:PORT
KOMODO_PLAYWRIGHT_FALLBACK=1
```
5. Run the watcher again.
If `PLAYWRIGHT_PROFILE_DIR` is set and a matching Chrome process is already running, `scripts/check_komodo.sh` will try to discover the CDP port automatically and use it instead of reopening the locked profile.
The helper scripts now support that same live-session route with:
- `PLAYWRIGHT_CDP_ENDPOINT`
- automatic local env loading from `.local/komodo-env.sh`
## Current Known Limits
- `Komodo` can block both `curl` and fresh browser automation.
- We can currently discover and report blocked media URLs even when we cannot download them automatically.
- Exported storage state may still be insufficient for Cloudflare-protected Komodo assets.
- Live trusted browser attach is currently the strongest repeatable fallback for protected Komodo media.
- The fully non-visual path is not reliable yet; after bootstrap, the working fallback is still a live dedicated browser running in the background.
## Recommended Workflow
Normal use:
1. run `scripts/run_watch.sh`
2. read `run-summary.md`
3. inspect the comparison report if something changed
4. update `status/current.md` and `status/evidence.jsonl` only for material changes
Escalation:
1. if Komodo blocks, continue the run
2. use `manual-asset-urls.txt` for manual follow-up
3. only use the Playwright bootstrap if protected Komodo media is important to the run
+253
View File
@@ -0,0 +1,253 @@
---
name: steam-hardware-watch
description: Investigate rumored or upcoming Valve hardware launches, especially Steam Controller, Steam Machine, and Steam Frame. Use when the user wants recurring checks across Komodo, SteamDB, SteamTracking or GameTracking, Valve CDN or support endpoints, and customs or regulatory sources, with saved artifacts and an updated status ledger.
---
# Steam Hardware Watch
Use this skill for recurring evidence-gathering on Valve hardware launch status. Default focus is:
- `price`
- `release date`
- `same-time launch vs staggered launch`
- `which product is furthest along`
Keep the workflow tiered. Do the high-yield checks every run. Do slower or noisier checks when core sources move.
This skill is meant to be run by a coding agent.
- prefer agent-executed scripts and saved artifacts over manual shell steps
- only involve the human when a source requires an interactive browser challenge or similar trust/bootstrap step
- when a human step is required, keep it short, explicit, and resumable by the agent
Important scope rule:
- saved visual assets should be limited to `Steam Controller`, `Steam Machine`, and `Steam Frame`
- do not bulk-save generic `Steam Deck` marketing media unless the user explicitly asks for it
## Output Contract
Every run must:
1. Create or reuse a dated artifact folder.
2. Save raw artifacts there:
- API responses
- screenshots
- downloaded media
- extracted frames when relevant
3. Update [status/current.md](status/current.md).
4. Append material findings to [status/evidence.jsonl](status/evidence.jsonl).
5. Create or update a dated run note in [status/runs](status/runs).
6. Compare against the previous run when available and save a delta summary.
7. Save newly discovered visual assets into the run folder.
8. Generate a draft status update from the run artifacts.
Use [scripts/init_run.sh](scripts/init_run.sh) to scaffold a run folder and run note. Use [scripts/append_evidence.py](scripts/append_evidence.py) for the JSONL ledger.
Use the source helpers when available:
- [scripts/run_watch.sh](scripts/run_watch.sh)
- [scripts/check_komodo.sh](scripts/check_komodo.sh)
- [scripts/check_steamdb.sh](scripts/check_steamdb.sh)
- [scripts/check_steamtracking.sh](scripts/check_steamtracking.sh)
- [scripts/check_valve_endpoints.sh](scripts/check_valve_endpoints.sh)
- [scripts/compare_runs.py](scripts/compare_runs.py)
- [scripts/save_visual_assets.py](scripts/save_visual_assets.py)
- [scripts/draft_status_update.py](scripts/draft_status_update.py)
Asset discovery should be dynamic:
- discover from current-run Komodo metadata when available
- fall back to previously observed run artifacts when a later run is blocked
- never hardcode a fixed asset list into the skill itself
Default agent entry point:
- use [scripts/run_watch.sh](scripts/run_watch.sh) unless there is a specific reason to run a source helper directly
- treat the helper scripts as agent-owned implementation details
- summarize results for the human from `run-summary.md`, `status-draft.md`, and the comparison report
## Evidence Priority
Apply the rubric in [references/evidence-rubric.md](references/evidence-rubric.md).
Default source order:
1. `Komodo`
2. `SteamDB`
3. `SteamTracking / GameTracking`
4. `Valve support / store / CDN`
5. `customs / regulatory`
Treat price and exact release date as unconfirmed unless directly exposed by a primary source.
## Core Workflow
### 1. Komodo
Read [references/komodo-api-notes.md](references/komodo-api-notes.md) first.
Check:
- product records for `Steam Controller`, `Steam Machine`, `Steam Frame`
- `sections` posts and attached `media`
- public `wp-json` routes
- changes in:
- product state
- timestamps
- fresh section sequences
- fresh videos, spec art, manuals, support docs
Save useful responses under the dated run folder.
Treat Komodo as `best effort`. Cloudflare may block scripted access even when the same endpoints worked earlier.
If raw `curl` gets blocked, record the block cleanly and continue the rest of the run.
If a user needs protected Komodo media and can solve a browser challenge manually, prefer an optional Playwright profile or storage-state bootstrap over hardcoded cookies or asset manifests.
Agent behavior for Komodo fallback:
1. try the normal automated path first
2. if blocked, continue the run and report:
- blocked source state
- discovered vs retrieved vs blocked assets
- manual retry URLs
3. only if protected Komodo media is important for the user’s goal, ask for or use the optional Playwright bootstrap path
4. keep any browser profile or storage-state file user-local and out of committed repo state
Preferred human-in-the-loop fallback:
- use [scripts/bootstrap_komodo.sh](scripts/bootstrap_komodo.sh) to launch a dedicated Komodo browser in the background
- let the user solve any challenge only in that isolated browser when needed
- keep that browser running while the agent reuses it over `CDP`
- use [scripts/close_komodo.sh](scripts/close_komodo.sh) when finished
### 2. SteamDB
Check the controller app, packages, depots, and history. Prefer primary records.
Watch for:
- new apps or packages
- hidden or owner-only videos
- unboxing assets
- depot changes
- release-state changes
- CDN media or manuals linked from SteamDB
Known IDs and starting points are in [references/sources.md](references/sources.md).
The helper script saves the raw HTML pages. Summarize only material changes.
### 3. SteamTracking / GameTracking
Look for:
- onboarding text
- firmware update strings
- pairing flow strings
- device type names or codenames
- support or OOBE changes
Only summarize findings that materially affect launch-readiness or architecture inference.
### 4. Valve Support / Store / CDN
Run these every time if quick:
- support pages
- store app endpoints
- discoverable CDN assets
- manuals, spec sheets, legal pages, safety docs
Save any newly exposed files.
The helper script is intentionally conservative. Add extra URLs when new official pages appear.
Do not treat generic Steam Deck site media as relevant unless it is directly tied to controller, machine, or frame evidence.
### 5. Customs / Regulatory
Run when:
- Komodo gets fresh media or sections
- SteamDB gets fresh app or package movement
- SteamTracking gets materially new rollout strings
- or on a slower periodic cadence
Use for confirmation, not as the primary source of truth.
## What Counts As Meaningful
Strong examples:
- new controller-only Komodo section rollout
- new SteamDB unboxing or store media
- published price or release date
- controller-specific support or legal docs
- shipment or filing records matching the new device
Weak examples:
- cosmetic class toggles
- generic WooCommerce script presence
- reused placeholder assets
- site-wide template churn without product-specific assets
## Run Note Format
Each dated run note should answer:
- What changed since the last run?
- Did anything change price confidence?
- Did anything change release-date confidence?
- Did anything change same-time-launch confidence?
- What remains the strongest evidence?
- What should be checked next?
Keep [status/current.md](status/current.md) short. Put narrative detail in the dated run note.
If a previous run exists, use `compare_runs.py` and fold the material deltas into the run note.
## Status Update Rules
Update `current.md` as the dashboard, not the archive.
Required sections:
- `Last updated`
- `Best current answer`
- `Source snapshot`
- `Open questions`
- `Next checks`
Append one JSON object per material finding to `evidence.jsonl`. Prefer one claim per line.
## Known Baseline
As of `2026-04-24`:
- no confirmed price leak
- no exact release date leak
- strongest controller-specific signals came from:
- SteamDB hidden unboxing asset
- Komodo controller-only section and media rollout
- SteamTracking onboarding and puck/pairing flows
- Komodo still shows `Steam Machine` and `Steam Frame` as real but dormant product records with older media only
Use the seeded status files as the starting baseline rather than rediscovering this from scratch.
## Wrapper Script
Use [scripts/run_watch.sh](scripts/run_watch.sh) for the normal path.
It will:
1. initialize the run folder
2. run the Komodo, SteamDB, SteamTracking, and Valve endpoint checks
3. auto-detect the previous run folder when possible
4. write a comparison report into the current run folder
5. save discovered visual assets into the current run folder
6. generate a draft status update into the current run folder
Example:
```sh
~/.codex/skills/steam-hardware-watch/scripts/run_watch.sh 2026-04-25 ~/steam_hardware_watch /tmp/SteamTracking-master
```
+4
View File
@@ -0,0 +1,4 @@
version: 1
display_name: Steam Hardware Watch
short_description: Reusable workflow for monitoring Valve hardware launch signals across Komodo, SteamDB, SteamTracking, Valve endpoints, and related evidence sources.
default_prompt: Check the current status of rumored or upcoming Valve hardware, save artifacts, and update the status ledger.
+30
View File
@@ -0,0 +1,30 @@
# Evidence Rubric
## Strong
- official page or API record
- SteamDB app, package, depot, or history change
- downloadable media, manuals, or support docs
- public shipment or regulatory filing clearly matching the device
## Medium
- client strings or protobufs describing concrete rollout flows
- hidden sections and media exposed by public site APIs
- product metadata showing real SKUs or rollout state without price or date
## Weak
- CSS class toggles
- generic platform scripts
- reused templates
- stale assets without fresh timestamps
- inference unsupported by product-specific artifacts
## Confidence Labels
- `high`: direct primary evidence, little ambiguity
- `medium`: strong inference from primary artifacts
- `low`: plausible but not confirmed
Use the lowest defensible confidence that matches the evidence.
+78
View File
@@ -0,0 +1,78 @@
# Komodo API Notes
## Useful Endpoints
- `/wp-json/`
- `/wp-json/wp/v2/product`
- `/wp-json/wp/v2/sections`
- `/wp-json/wp/v2/media`
## What Worked
- product lookups by ID or slug
- section searches
- media searches
- media by `parent`
## What To Look For
- `publish` vs hidden states
- `product-type-simple` or other type changes
- `outofstock`
- `wishlist-only`
- new `sections` posts
- fresh `2026/..` uploads
- videos, spec art, manuals, safety docs
## Important Pitfalls
- Raw `curl` can hit Cloudflare or DNS/sandbox issues.
- As of clean packaging tests, Cloudflare can block both `curl` and browser automation paths.
- Treat Komodo automation as `best effort`, not guaranteed.
- If protected media matters, use an optional Playwright bootstrap path instead of hardcoding cookies:
- launch a persistent browser profile
- solve the challenge manually once
- save storage state or reuse the profile on later runs
- if storage state is not enough, attach to the still-running trusted browser over CDP
- A low-disruption background flow is preferred:
- launch a dedicated Komodo Chrome instance in the background
- leave it running
- let the agent reuse it over CDP
- close it when the run is done
- Frontend CSS class toggles were low-yield. The public JSON endpoints were much more useful than DOM manipulation.
- Fresh media attached to `sections` posts mattered more than old product-page HTML.
- If Komodo is blocked on a given run, keep the rest of the pipeline going and record the block in the reports.
## Optional Playwright Bootstrap
Use only as a fallback when Komodo blocks normal automation and you need to recover protected media or JSON.
Repeatable pattern:
1. `playwright-cli open --persistent --profile /path/to/profile https://komodostation.com/product/steam-controller_jpy/`
2. Solve any Cloudflare challenge manually in that browser.
3. Optionally save storage state:
- `playwright-cli state-save /path/to/komodo-state.json`
4. Reuse in helper scripts with environment variables:
- `PLAYWRIGHT_PROFILE_DIR=/path/to/profile`
- or `PLAYWRIGHT_STORAGE_STATE=/path/to/komodo-state.json`
- optional `PLAYWRIGHT_REFERER=https://komodostation.com/product/steam-controller_jpy/`
If the saved storage state still gets blocked, use the stronger live-session path:
1. Keep the trusted Playwright browser running on the same profile.
2. Find the local `--remote-debugging-port` for that profile with `ps`.
3. Set:
- `PLAYWRIGHT_CDP_ENDPOINT=http://127.0.0.1:PORT`
- `KOMODO_PLAYWRIGHT_FALLBACK=1`
4. Run the helper scripts again.
`check_komodo.sh` will try to auto-detect a CDP endpoint from the running profile when `PLAYWRIGHT_PROFILE_DIR` is set.
This keeps the repo reusable: the bootstrap is optional, user-local, and not committed.
## Known Baseline On 2026-04-24
- Controller had a fresh same-day section/media rollout.
- Machine and Frame only exposed old wishlist sections and older assets.
- Product records for all three were real published WooCommerce entries in the same held state.
+82
View File
@@ -0,0 +1,82 @@
# Source Map
Use primary sources first.
## Komodo
- `https://komodostation.com/wp-json/`
- `https://komodostation.com/wp-json/wp/v2/product`
- `https://komodostation.com/wp-json/wp/v2/sections`
- `https://komodostation.com/wp-json/wp/v2/media`
Known product IDs from the seeded baseline:
- `Steam Controller JPY`: `413763`
- `Steam Machine JPY`: `413772`
- `Steam Frame JPY`: `413776`
Known section IDs from `2026-04-24` controller rollout:
- `433306` banner
- `433326` people video
- `433356` features
- `433376` video
- `433416` buttons
- `433434` custom
- `433470` features
- `433484` spec image
## SteamDB
- `Steam Controller app`: `https://steamdb.info/app/4165870/`
- `Steam Controller history`: `https://steamdb.info/app/4165870/history/`
- `Unboxing video app`: `https://steamdb.info/app/4653940/`
- `Unboxing video package`: `https://steamdb.info/sub/1620489/`
## SteamTracking / GameTracking
Primary local paths if already cloned:
- `/tmp/SteamTracking-master`
- `/tmp/GameTracking-*`
High-yield files from the seeded baseline:
- `ClientExtracted/steamui/localization/steamui_english.json`
- `Protobufs/webuimessages_steaminput.proto`
- `Protobufs/steammessages_clientsettings.proto`
- `steamcommunity.com/public/javascript/webui/friends.js`
- `Strings/steamclient.txt`
Useful search terms:
- `Triton`
- `Ibex`
- `Puck`
- `Steam Controller`
- `dock`
- `pair`
- `firmware`
## Valve Endpoints
Check for newly exposed assets or support flows:
- Steam support
- Steam store app pages
- linked manuals, PDFs, videos, and images
- `https://store.steampowered.com/app/4165870/`
- `https://help.steampowered.com/en/wizard/HelpWithSteamDeck`
- `https://www.steamdeck.com/`
- `https://steampowered.com/hardware`
## Customs / Regulatory
Use as confirmation when primary signals move:
- customs and import records
- FCC
- Bluetooth SIG
- Wi-Fi Alliance
Do not elevate these above direct Komodo, SteamDB, or Valve evidence when those primary sources disagree.
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
import argparse
import json
from pathlib import Path
def main() -> None:
parser = argparse.ArgumentParser(description="Append one evidence record to the skill JSONL ledger.")
parser.add_argument("--file", required=True, help="Path to evidence.jsonl")
parser.add_argument("--date-checked", required=True)
parser.add_argument("--source", required=True)
parser.add_argument("--entity", required=True)
parser.add_argument("--claim", required=True)
parser.add_argument("--evidence-type", required=True)
parser.add_argument("--confidence", required=True, choices=["high", "medium", "low"])
parser.add_argument("--ref", required=True, help="URL or local path")
parser.add_argument("--notes", default="")
args = parser.parse_args()
record = {
"date_checked": args.date_checked,
"source": args.source,
"entity": args.entity,
"claim": args.claim,
"evidence_type": args.evidence_type,
"confidence": args.confidence,
"ref": args.ref,
"notes": args.notes,
}
target = Path(args.file)
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(record, ensure_ascii=True) + "\n")
if __name__ == "__main__":
main()
+62
View File
@@ -0,0 +1,62 @@
#!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
REPO_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)"
LOCAL_DIR="$REPO_DIR/.local"
PROFILE_DIR="${1:-${KOMODO_PROFILE_DIR:-$LOCAL_DIR/playwright-komodo-profile}}"
PORT="${2:-${KOMODO_CDP_PORT:-55684}}"
URL="${KOMODO_BOOTSTRAP_URL:-https://komodostation.com/product/steam-controller_jpy/}"
ENV_FILE="${KOMODO_ENV_FILE:-$LOCAL_DIR/komodo-env.sh}"
mkdir -p "$PROFILE_DIR" "$LOCAL_DIR"
existing_pid="$(ps aux | awk -v profile="$PROFILE_DIR" -v port="$PORT" '
index($0, "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome") &&
index($0, profile) &&
index($0, "--remote-debugging-port=" port) {
print $2
exit
}
')"
if [ -z "$existing_pid" ]; then
open -g -na "Google Chrome" --args \
"--user-data-dir=$PROFILE_DIR" \
"--remote-debugging-port=$PORT" \
--no-first-run \
--disable-sync \
--new-window \
"$URL"
attempts=0
until curl -fsS "http://127.0.0.1:$PORT/json/version" >/dev/null 2>&1; do
attempts=$((attempts + 1))
if [ "$attempts" -ge 30 ]; then
echo "failed to observe a Chrome CDP endpoint on port $PORT" >&2
exit 1
fi
sleep 1
done
else
echo "Komodo bootstrap browser already running on port $PORT (pid $existing_pid)."
fi
cat > "$ENV_FILE" <<EOF
export KOMODO_PLAYWRIGHT_FALLBACK=1
export PLAYWRIGHT_PROFILE_DIR="$PROFILE_DIR"
export PLAYWRIGHT_CDP_ENDPOINT="http://127.0.0.1:$PORT"
export PLAYWRIGHT_BROWSER_CHANNEL="chrome"
export PLAYWRIGHT_REFERER="https://komodostation.com/product/steam-controller_jpy/"
EOF
printf '%s\n' "Komodo browser bootstrap ready."
printf '%s\n' "Profile: $PROFILE_DIR"
printf '%s\n' "CDP endpoint: http://127.0.0.1:$PORT"
printf '%s\n' "Env file: $ENV_FILE"
printf '%s\n' ""
printf '%s\n' "Next steps:"
printf '%s\n' "1. If Komodo needs any manual interaction, switch to the dedicated Chrome window when convenient."
printf '%s\n' "2. Leave that dedicated browser running in the background."
printf '%s\n' "3. Source the env file before a run, or let check_komodo.sh pick it up automatically."
+152
View File
@@ -0,0 +1,152 @@
#!/bin/sh
set -eu
if [ "$#" -lt 1 ]; then
echo "usage: $0 RUN_DIR" >&2
exit 1
fi
RUN_DIR="$1"
OUT_DIR="$RUN_DIR/api/komodo"
REPORT_DIR="$RUN_DIR/reports"
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
REPO_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)"
mkdir -p "$OUT_DIR" "$REPORT_DIR"
ERROR_FILE="$REPORT_DIR/komodo-errors.txt"
: > "$REPORT_DIR/komodo-product-modified.tsv"
: > "$REPORT_DIR/komodo-controller-sections.tsv"
: > "$REPORT_DIR/komodo-machine-sections.tsv"
: > "$REPORT_DIR/komodo-frame-sections.tsv"
: > "$REPORT_DIR/komodo-controller-media-interesting.tsv"
: > "$REPORT_DIR/komodo-machine-media.tsv"
: > "$REPORT_DIR/komodo-frame-media.tsv"
: > "$REPORT_DIR/komodo-visual-assets.tsv"
: > "$ERROR_FILE"
load_local_env() {
env_file="${KOMODO_ENV_FILE:-$REPO_DIR/.local/komodo-env.sh}"
if [ -f "$env_file" ]; then
# shellcheck disable=SC1090
. "$env_file"
fi
}
maybe_set_cdp_endpoint() {
if [ -n "${PLAYWRIGHT_CDP_ENDPOINT:-}" ] || [ -z "${PLAYWRIGHT_PROFILE_DIR:-}" ]; then
return 0
fi
port="$(ps aux | awk -v profile="$PLAYWRIGHT_PROFILE_DIR" '
index($0, profile) && match($0, /--remote-debugging-port=([0-9]+)/) {
value = substr($0, RSTART, RLENGTH)
sub(/^--remote-debugging-port=/, "", value)
print value
exit
}
' 2>/dev/null || true)"
if [ -n "$port" ]; then
PLAYWRIGHT_CDP_ENDPOINT="http://127.0.0.1:$port"
export PLAYWRIGHT_CDP_ENDPOINT
fi
}
load_local_env
fetch_json() {
name="$1"
url="$2"
allow_fallback="${3:-1}"
tmp="$OUT_DIR/$name.json.tmp"
if curl -A 'Mozilla/5.0' --retry 1 --retry-delay 1 --max-time 12 -fsSL "$url" > "$tmp" 2>/dev/null \
&& jq empty "$tmp" >/dev/null 2>&1; then
mv "$tmp" "$OUT_DIR/$name.json"
return 0
fi
rm -f "$tmp"
if [ "$allow_fallback" = "1" ] && [ "${KOMODO_PLAYWRIGHT_FALLBACK:-0}" = "1" ] && command -v playwright-cli >/dev/null 2>&1; then
maybe_set_cdp_endpoint
PLAYWRIGHT_CLI_BIN="$(command -v playwright-cli)"
PLAYWRIGHT_CORE_PATH="$(CDPATH= cd -- "$(dirname "$PLAYWRIGHT_CLI_BIN")/../lib/node_modules/@playwright/cli/node_modules/playwright-core" && pwd 2>/dev/null || true)"
PLAYWRIGHT_CORE_PATH="$PLAYWRIGHT_CORE_PATH" node "$SCRIPT_DIR/fetch_with_playwright.js" "$url" "$tmp"
if jq empty "$tmp" >/dev/null 2>&1; then
mv "$tmp" "$OUT_DIR/$name.json"
return 0
fi
fi
rm -f "$tmp"
printf '%s\n' "failed to fetch JSON from $url" >> "$ERROR_FILE"
return 1
}
if ! fetch_json "wp-json-root" "https://komodostation.com/wp-json/"; then
printf '%s\n' "Komodo API unavailable or blocked; leaving Komodo reports empty." >> "$ERROR_FILE"
printf '%s\n' "Komodo fetch blocked; see $ERROR_FILE"
exit 0
fi
if ! fetch_json "product-controller-jpy" "https://komodostation.com/wp-json/wp/v2/product/413763"; then
printf '%s\n' "Komodo product fetch blocked; leaving Komodo reports empty." >> "$ERROR_FILE"
printf '%s\n' "Komodo fetch blocked; see $ERROR_FILE"
exit 0
fi
fetch_json "product-machine-jpy" "https://komodostation.com/wp-json/wp/v2/product/413772" 0 || true
fetch_json "product-frame-jpy" "https://komodostation.com/wp-json/wp/v2/product/413776" 0 || true
fetch_json "sections-controller-search" "https://komodostation.com/wp-json/wp/v2/sections?search=Steam%20Controller&per_page=100" || true
fetch_json "sections-machine-search" "https://komodostation.com/wp-json/wp/v2/sections?search=Steam%20Machine&per_page=100" 0 || true
fetch_json "sections-frame-search" "https://komodostation.com/wp-json/wp/v2/sections?search=Steam%20Frame&per_page=100" 0 || true
fetch_json "media-controller-search" "https://komodostation.com/wp-json/wp/v2/media?search=Steam%20Controller&per_page=100" || true
fetch_json "media-parent-product-controller" "https://komodostation.com/wp-json/wp/v2/media?parent=413763&per_page=100" || true
for id in $(jq -r '.[].id' "$OUT_DIR/sections-controller-search.json" 2>/dev/null || true); do
fetch_json "media-parent-section-$id" "https://komodostation.com/wp-json/wp/v2/media?parent=$id&per_page=100" || true
done
{
printf 'controller\t%s\n' "$(jq -r '.modified // "n/a"' "$OUT_DIR/product-controller-jpy.json" 2>/dev/null || printf 'n/a')"
printf 'machine\t%s\n' "$(jq -r '.modified // "n/a"' "$OUT_DIR/product-machine-jpy.json" 2>/dev/null || printf 'n/a')"
printf 'frame\t%s\n' "$(jq -r '.modified // "n/a"' "$OUT_DIR/product-frame-jpy.json" 2>/dev/null || printf 'n/a')"
} > "$REPORT_DIR/komodo-product-modified.tsv"
jq -r '.[]? | [.id,.date,.modified,.slug,.title.rendered] | @tsv' \
"$OUT_DIR/sections-controller-search.json" \
> "$REPORT_DIR/komodo-controller-sections.tsv"
jq -r '.[]? | [.id,.date,.modified,.slug,.title.rendered] | @tsv' \
"$OUT_DIR/sections-machine-search.json" \
> "$REPORT_DIR/komodo-machine-sections.tsv"
jq -r '.[]? | [.id,.date,.modified,.slug,.title.rendered] | @tsv' \
"$OUT_DIR/sections-frame-search.json" \
> "$REPORT_DIR/komodo-frame-sections.tsv"
jq -r '
.[]?
| select((.source_url + " " + .slug + " " + .title.rendered) | test("2026/|webm|mp4|avif|spec|manual"; "i"))
| [.id,.date,.modified,.slug,.mime_type,.post,.source_url]
| @tsv
' "$OUT_DIR/media-controller-search.json" > "$REPORT_DIR/komodo-controller-media-interesting.tsv"
: > "$REPORT_DIR/komodo-machine-media.tsv"
: > "$REPORT_DIR/komodo-frame-media.tsv"
jq -r '
.[]?
| select((.mime_type | startswith("image/")) or (.mime_type | startswith("video/")))
| [.id,.mime_type,.source_url]
| @tsv
' "$OUT_DIR"/media-*.json "$OUT_DIR"/media-parent-*.json 2>/dev/null \
| sort -u \
> "$REPORT_DIR/komodo-visual-assets.tsv"
printf '%s\n' \
"Saved Komodo API responses to $OUT_DIR" \
"Saved Komodo summaries to $REPORT_DIR"
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
set -eu
if [ "$#" -lt 1 ]; then
echo "usage: $0 RUN_DIR" >&2
exit 1
fi
RUN_DIR="$1"
OUT_DIR="$RUN_DIR/api/steamdb"
REPORT_DIR="$RUN_DIR/reports"
mkdir -p "$OUT_DIR" "$REPORT_DIR"
ERROR_FILE="$REPORT_DIR/steamdb-errors.txt"
: > "$ERROR_FILE"
: > "$REPORT_DIR/steamdb-key-lines.txt"
fetch_html() {
name="$1"
url="$2"
if ! curl -A 'Mozilla/5.0' --retry 2 --retry-delay 2 --max-time 45 -fsSL "$url" > "$OUT_DIR/$name.html" 2>/dev/null; then
printf '%s\n' "failed to fetch $url" >> "$ERROR_FILE"
rm -f "$OUT_DIR/$name.html"
return 1
fi
}
fetch_html "controller-app" "https://steamdb.info/app/4165870/" || true
fetch_html "controller-history" "https://steamdb.info/app/4165870/history/" || true
fetch_html "controller-unboxing-app" "https://steamdb.info/app/4653940/" || true
fetch_html "controller-unboxing-package" "https://steamdb.info/sub/1620489/" || true
rg -n "Coming soon|released|ownersonly|free on demand|unboxing|package|depot|video" \
"$OUT_DIR" \
> "$REPORT_DIR/steamdb-key-lines.txt" || true
printf '%s\n' \
"Saved SteamDB pages to $OUT_DIR" \
"Saved matching lines to $REPORT_DIR/steamdb-key-lines.txt"
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
set -eu
if [ "$#" -lt 1 ]; then
echo "usage: $0 RUN_DIR [TRACKING_DIR]" >&2
exit 1
fi
RUN_DIR="$1"
TRACKING_DIR="${2:-/tmp/SteamTracking-master}"
OUT_DIR="$RUN_DIR/reports"
mkdir -p "$OUT_DIR"
if [ ! -d "$TRACKING_DIR" ]; then
echo "missing tracking dir: $TRACKING_DIR" >&2
exit 1
fi
rg -n -i "Triton|Ibex|Puck|Steam Controller|firmware|pair|dock|dongle" \
"$TRACKING_DIR" \
> "$OUT_DIR/steamtracking-hits.txt" || true
rg -n -i "ShouldTritonPairInOobe|PairDongleTriton|UnpairedTriton|ibex_internal|ibex_external" \
"$TRACKING_DIR" \
> "$OUT_DIR/steamtracking-pairing-focus.txt" || true
printf '%s\n' \
"Saved SteamTracking grep output to $OUT_DIR/steamtracking-hits.txt" \
"Saved pairing-focused grep output to $OUT_DIR/steamtracking-pairing-focus.txt"
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
set -eu
if [ "$#" -lt 1 ]; then
echo "usage: $0 RUN_DIR" >&2
exit 1
fi
RUN_DIR="$1"
OUT_DIR="$RUN_DIR/api/valve"
REPORT_DIR="$RUN_DIR/reports"
mkdir -p "$OUT_DIR" "$REPORT_DIR"
ERROR_FILE="$REPORT_DIR/valve-errors.txt"
: > "$ERROR_FILE"
: > "$REPORT_DIR/valve-key-lines.txt"
fetch_page() {
name="$1"
url="$2"
if ! curl -A 'Mozilla/5.0' --retry 2 --retry-delay 2 --max-time 45 -fsSL "$url" > "$OUT_DIR/$name.html" 2>/dev/null; then
printf '%s\n' "failed to fetch $url" >> "$ERROR_FILE"
rm -f "$OUT_DIR/$name.html"
return 1
fi
}
fetch_page "steamdeck-site" "https://www.steamdeck.com/" || true
fetch_page "steam-support-deck" "https://help.steampowered.com/en/wizard/HelpWithSteamDeck" || true
fetch_page "steam-controller-store" "https://store.steampowered.com/app/4165870/" || true
fetch_page "steam-hardware-root" "https://steampowered.com/hardware" || true
rg -n "Steam Controller|Steam Machine|Steam Frame|coming soon|manual|safety|warranty|preorder|release" \
"$OUT_DIR" \
> "$REPORT_DIR/valve-key-lines.txt" || true
printf '%s\n' \
"Saved Valve pages to $OUT_DIR" \
"Saved matching lines to $REPORT_DIR/valve-key-lines.txt"
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
REPO_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)"
LOCAL_DIR="$REPO_DIR/.local"
PROFILE_DIR="${1:-${KOMODO_PROFILE_DIR:-$LOCAL_DIR/playwright-komodo-profile}}"
ENV_FILE="${KOMODO_ENV_FILE:-$LOCAL_DIR/komodo-env.sh}"
pids="$(ps aux | awk -v profile="$PROFILE_DIR" '
index($0, profile) && !index($0, "awk -v profile=") {
print $2
}
')"
if [ -n "$pids" ]; then
for pid in $pids; do
kill "$pid" 2>/dev/null || true
done
printf '%s\n' "Closed dedicated Komodo browser/profile processes for $PROFILE_DIR."
else
printf '%s\n' "No dedicated Komodo browser/profile processes found for $PROFILE_DIR."
fi
rm -f \
"$PROFILE_DIR/SingletonLock" \
"$PROFILE_DIR/SingletonSocket" \
"$PROFILE_DIR/SingletonCookie"
if [ -f "$ENV_FILE" ]; then
rm -f "$ENV_FILE"
printf '%s\n' "Removed $ENV_FILE"
fi
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
import argparse
import json
from pathlib import Path
def read_json(path: Path):
if not path.exists():
return None
return json.loads(path.read_text(encoding="utf-8"))
def read_lines(path: Path):
if not path.exists():
return []
return [line.rstrip("\n") for line in path.read_text(encoding="utf-8").splitlines()]
def compare_products(prev_dir: Path, curr_dir: Path):
results = []
for label, name in [
("controller", "product-controller-jpy.json"),
("machine", "product-machine-jpy.json"),
("frame", "product-frame-jpy.json"),
]:
prev = read_json(prev_dir / "api" / "komodo" / name)
curr = read_json(curr_dir / "api" / "komodo" / name)
if not prev or not curr:
continue
prev_mod = prev.get("modified")
curr_mod = curr.get("modified")
if prev_mod != curr_mod:
results.append(f"- Komodo {label} product modified changed: `{prev_mod}` -> `{curr_mod}`")
return results
def parse_section_tsv(path: Path):
rows = {}
for line in read_lines(path):
if not line.strip():
continue
parts = line.split("\t")
if len(parts) < 5:
continue
sid, date, modified, slug, title = parts[:5]
rows[sid] = {
"date": date,
"modified": modified,
"slug": slug,
"title": title,
}
return rows
def compare_sections(prev_dir: Path, curr_dir: Path, label: str, filename: str):
prev = parse_section_tsv(prev_dir / "reports" / filename)
curr = parse_section_tsv(curr_dir / "reports" / filename)
lines = []
added_ids = sorted(set(curr) - set(prev), key=int)
removed_ids = sorted(set(prev) - set(curr), key=int)
shared_ids = sorted(set(curr) & set(prev), key=int)
for sid in added_ids:
row = curr[sid]
lines.append(f"- {label} section added: `{sid}` `{row['slug']}` modified `{row['modified']}`")
for sid in removed_ids:
row = prev[sid]
lines.append(f"- {label} section removed: `{sid}` `{row['slug']}`")
for sid in shared_ids:
if prev[sid]["modified"] != curr[sid]["modified"]:
lines.append(
f"- {label} section updated: `{sid}` `{curr[sid]['slug']}` `{prev[sid]['modified']}` -> `{curr[sid]['modified']}`"
)
return lines
def parse_tsv_set(path: Path):
return {line for line in read_lines(path) if line.strip()}
def compare_report_sets(prev_dir: Path, curr_dir: Path, label: str, filename: str):
prev = parse_tsv_set(prev_dir / "reports" / filename)
curr = parse_tsv_set(curr_dir / "reports" / filename)
lines = []
for line in sorted(curr - prev):
lines.append(f"- {label} added: `{line}`")
for line in sorted(prev - curr):
lines.append(f"- {label} removed: `{line}`")
return lines
def compare_text_lines(prev_dir: Path, curr_dir: Path, label: str, filename: str, limit: int = 20):
prev = parse_tsv_set(prev_dir / "reports" / filename)
curr = parse_tsv_set(curr_dir / "reports" / filename)
added = sorted(curr - prev)[:limit]
removed = sorted(prev - curr)[:limit]
lines = []
for line in added:
lines.append(f"- {label} added line: `{line}`")
for line in removed:
lines.append(f"- {label} removed line: `{line}`")
return lines
def build_summary(prev_dir: Path, curr_dir: Path):
lines = [
"# Run Comparison",
"",
f"Previous: `{prev_dir}`",
f"Current: `{curr_dir}`",
"",
"## Material Deltas",
"",
]
deltas = []
deltas.extend(compare_products(prev_dir, curr_dir))
deltas.extend(compare_sections(prev_dir, curr_dir, "Komodo controller", "komodo-controller-sections.tsv"))
deltas.extend(compare_sections(prev_dir, curr_dir, "Komodo machine", "komodo-machine-sections.tsv"))
deltas.extend(compare_sections(prev_dir, curr_dir, "Komodo frame", "komodo-frame-sections.tsv"))
deltas.extend(compare_report_sets(prev_dir, curr_dir, "Komodo controller media", "komodo-controller-media-interesting.tsv"))
deltas.extend(compare_report_sets(prev_dir, curr_dir, "Komodo machine media", "komodo-machine-media.tsv"))
deltas.extend(compare_report_sets(prev_dir, curr_dir, "Komodo frame media", "komodo-frame-media.tsv"))
deltas.extend(compare_text_lines(prev_dir, curr_dir, "SteamDB", "steamdb-key-lines.txt"))
deltas.extend(compare_text_lines(prev_dir, curr_dir, "Valve endpoint", "valve-key-lines.txt"))
if deltas:
lines.extend(deltas)
else:
lines.append("- No material deltas detected in the compared reports.")
lines.extend(
[
"",
"## Notes",
"",
"- This comparison is intentionally conservative.",
"- It only covers artifacts produced by the helper scripts.",
"- Review raw files when a line item looks ambiguous or noisy.",
"",
]
)
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="Compare two Steam hardware watch run directories.")
parser.add_argument("--previous", required=True, help="Previous run directory")
parser.add_argument("--current", required=True, help="Current run directory")
parser.add_argument("--output", required=True, help="Path to write markdown summary")
args = parser.parse_args()
prev_dir = Path(args.previous)
curr_dir = Path(args.current)
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(build_summary(prev_dir, curr_dir), encoding="utf-8")
if __name__ == "__main__":
main()
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env node
const fs = require("fs");
const { URL } = require("url");
function loadPlaywright() {
const candidates = [];
if (process.env.PLAYWRIGHT_CORE_PATH) candidates.push(process.env.PLAYWRIGHT_CORE_PATH);
candidates.push("playwright-core");
for (const candidate of candidates) {
try {
return require(candidate);
} catch (err) {
}
}
throw new Error("Unable to load playwright-core. Set PLAYWRIGHT_CORE_PATH or install playwright-core.");
}
function launchOptions(headless) {
const options = { headless };
if (process.env.PLAYWRIGHT_BROWSER_CHANNEL) {
options.channel = process.env.PLAYWRIGHT_BROWSER_CHANNEL;
}
return options;
}
function findKomodoPage(context, origin) {
const pages = context.pages();
return pages.find((candidate) => candidate.url().startsWith(origin)) || pages[0] || null;
}
async function main() {
const [, , targetUrl, outputPath, metaPath] = process.argv;
if (!targetUrl || !outputPath || !metaPath) {
console.error("usage: download_with_playwright.js URL OUTPUT_PATH META_PATH");
process.exit(1);
}
const { chromium } = loadPlaywright();
const headless = process.env.PLAYWRIGHT_HEADLESS !== "0";
const profileDir = process.env.PLAYWRIGHT_PROFILE_DIR;
const storageState = process.env.PLAYWRIGHT_STORAGE_STATE;
const referer = process.env.PLAYWRIGHT_REFERER;
const cdpEndpoint = process.env.PLAYWRIGHT_CDP_ENDPOINT;
const fetchTimeoutMs = Number(process.env.PLAYWRIGHT_FETCH_TIMEOUT_MS || 20000);
let browser = null;
let context = null;
let page = null;
let shouldCloseBrowser = false;
let shouldCloseContext = false;
let shouldClosePage = false;
if (cdpEndpoint) {
browser = await chromium.connectOverCDP(cdpEndpoint);
shouldCloseBrowser = true;
context = browser.contexts()[0];
if (!context) {
throw new Error(`No browser context available via CDP endpoint ${cdpEndpoint}`);
}
const origin = new URL(targetUrl).origin + "/";
page = findKomodoPage(context, origin);
if (!page) {
page = await context.newPage();
shouldClosePage = true;
}
} else if (profileDir) {
context = await chromium.launchPersistentContext(profileDir, {
...launchOptions(headless),
userAgent: "Mozilla/5.0",
});
shouldCloseContext = true;
page = context.pages()[0] || await context.newPage();
} else {
browser = await chromium.launch(launchOptions(headless));
shouldCloseBrowser = true;
context = await browser.newContext({
userAgent: "Mozilla/5.0",
storageState: storageState || undefined,
extraHTTPHeaders: referer ? { Referer: referer } : undefined,
});
shouldCloseContext = true;
page = await context.newPage();
}
try {
let result;
if (cdpEndpoint) {
const origin = new URL(targetUrl).origin + "/";
if (!page.url().startsWith(origin)) {
await page.goto(origin, {
waitUntil: "domcontentloaded",
timeout: 45000,
});
}
result = await page.evaluate(async ({ url, timeoutMs }) => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(url, { credentials: "include", signal: controller.signal });
clearTimeout(timeout);
const buffer = await response.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = "";
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
const headers = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
return {
ok: response.ok,
status: response.status,
url: response.url,
headers,
bodyBase64: btoa(binary),
};
}, { url: targetUrl, timeoutMs: fetchTimeoutMs });
fs.writeFileSync(outputPath, Buffer.from(result.bodyBase64, "base64"));
} else {
const response = await page.goto(targetUrl, {
waitUntil: "domcontentloaded",
timeout: 45000,
});
if (!response) {
throw new Error("No response received from navigation");
}
const body = await response.body();
fs.writeFileSync(outputPath, body);
result = {
ok: response.ok(),
status: response.status(),
url: response.url(),
headers: response.headers(),
};
}
fs.writeFileSync(
metaPath,
JSON.stringify(
result,
null,
2
)
);
} finally {
if (shouldClosePage && page && !page.isClosed()) {
await page.close();
}
if (shouldCloseContext && context) {
await context.close();
}
if (shouldCloseBrowser && browser) {
await browser.close();
}
}
}
main().catch((err) => {
console.error(err && err.stack ? err.stack : String(err));
process.exit(1);
});
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
import argparse
from pathlib import Path
def read_lines(path: Path):
if not path.exists():
return []
return [line.rstrip("\n") for line in path.read_text(encoding="utf-8", errors="ignore").splitlines()]
def first_n_nonempty(path: Path, n: int):
return [line for line in read_lines(path) if line.strip()][:n]
def count_lines(path: Path):
return len([line for line in read_lines(path) if line.strip()])
def has_nonempty(path: Path):
return count_lines(path) > 0
def blocked_asset_urls(path: Path, n: int):
urls = []
if not path.exists():
return urls
for line in read_lines(path):
parts = line.split("\t")
if len(parts) > 4 and parts[4].startswith("http"):
urls.append(parts[4])
if len(urls) >= n:
break
return urls
def build_draft(run_dir: Path):
reports = run_dir / "reports"
run_date = run_dir.name
compare_files = sorted(reports.glob("compare-vs-*.md"))
compare_file = compare_files[-1] if compare_files else None
compare_lines = []
if compare_file:
compare_lines = [line for line in read_lines(compare_file) if line.startswith("- ")][:12]
komodo_mods = first_n_nonempty(reports / "komodo-product-modified.tsv", 10)
steamdb_lines = first_n_nonempty(reports / "steamdb-key-lines.txt", 8)
valve_lines = first_n_nonempty(reports / "valve-key-lines.txt", 8)
tracking_lines = first_n_nonempty(reports / "steamtracking-pairing-focus.txt", 8)
discovered_count = count_lines(reports / "discovered-visual-assets.tsv")
retrieved_count = count_lines(reports / "retrieved-visual-assets.tsv")
blocked_count = count_lines(reports / "blocked-visual-assets.tsv")
manual_urls = blocked_asset_urls(reports / "blocked-visual-assets.tsv", 10)
komodo_blocked = has_nonempty(reports / "komodo-errors.txt")
steamdb_blocked = has_nonempty(reports / "steamdb-errors.txt")
valve_blocked = has_nonempty(reports / "valve-errors.txt")
lines = [
f"# Status Draft: {run_date}",
"",
"## Suggested `status/current.md` Update",
"",
f"Last updated: `{run_date}`",
"",
"### Observed Changes Since Previous Run",
"",
]
if compare_lines:
lines.extend(compare_lines)
else:
lines.append("- No comparison report was available.")
lines.extend(
[
"",
"### Source Snapshot Draft",
"",
"#### Komodo",
]
)
if komodo_blocked:
lines.append("- Komodo was blocked or partially unavailable in this run. See `komodo-errors.txt`.")
elif komodo_mods:
lines.extend([f"- `{line}`" for line in komodo_mods])
else:
lines.append("- No Komodo product summary found.")
lines.append(f"- Discovered visual assets: `{discovered_count}`")
lines.append(f"- Retrieved visual assets: `{retrieved_count}`")
lines.append(f"- Blocked visual assets: `{blocked_count}`")
lines.extend(["", "#### SteamDB"])
if steamdb_blocked:
lines.append("- SteamDB had blocked or failed fetches in this run. See `steamdb-errors.txt`.")
elif steamdb_lines:
lines.extend([f"- `{line}`" for line in steamdb_lines])
else:
lines.append("- No SteamDB key-line report found.")
lines.extend(["", "#### SteamTracking / GameTracking"])
if tracking_lines:
lines.extend([f"- `{line}`" for line in tracking_lines])
else:
lines.append("- No focused SteamTracking report found.")
lines.extend(["", "#### Valve Support / CDN"])
if valve_blocked:
lines.append("- Some Valve endpoint fetches failed in this run. See `valve-errors.txt`.")
elif valve_lines:
lines.extend([f"- `{line}`" for line in valve_lines])
else:
lines.append("- No Valve key-line report found.")
lines.extend(
[
"",
"### Analyst Judgment Needed",
"",
"- Price confidence:",
"- Release date confidence:",
"- Same-time launch confidence:",
"",
"## Suggested Run Note Additions",
"",
f"- Artifacts live in `{run_dir}`",
f"- Discovered visual assets: `{reports / 'discovered-visual-assets.tsv'}`",
f"- Retrieved visual assets: `{reports / 'retrieved-visual-assets.tsv'}`",
f"- Blocked visual assets: `{reports / 'blocked-visual-assets.tsv'}`",
f"- Manual retry URLs: `{reports / 'manual-asset-urls.txt'}`",
]
)
if compare_file:
lines.append(f"- Comparison report: `{compare_file}`")
if manual_urls:
lines.extend(["", "- Manual retry candidates:"])
lines.extend([f"- `{url}`" for url in manual_urls])
lines.extend(
[
"",
"- Summarize only material deltas from the comparison report.",
"- If new visual assets were downloaded, call out the most meaningful ones explicitly.",
"",
]
)
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="Draft a status update from a run directory.")
parser.add_argument("--run-dir", required=True)
parser.add_argument("--output", help="Optional output path")
args = parser.parse_args()
run_dir = Path(args.run_dir)
output = Path(args.output) if args.output else run_dir / "reports" / "status-draft.md"
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(build_draft(run_dir), encoding="utf-8")
if __name__ == "__main__":
main()
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env node
const fs = require("fs");
const { URL } = require("url");
function loadPlaywright() {
const candidates = [];
if (process.env.PLAYWRIGHT_CORE_PATH) candidates.push(process.env.PLAYWRIGHT_CORE_PATH);
candidates.push("playwright-core");
for (const candidate of candidates) {
try {
return require(candidate);
} catch (err) {
}
}
throw new Error(
"Unable to load playwright-core. Set PLAYWRIGHT_CORE_PATH or install playwright-core."
);
}
function launchOptions(headless) {
const options = { headless };
if (process.env.PLAYWRIGHT_BROWSER_CHANNEL) {
options.channel = process.env.PLAYWRIGHT_BROWSER_CHANNEL;
}
return options;
}
function findKomodoPage(context, origin) {
const pages = context.pages();
return pages.find((candidate) => candidate.url().startsWith(origin)) || pages[0] || null;
}
async function main() {
const [, , targetUrl, outputPath] = process.argv;
if (!targetUrl || !outputPath) {
console.error("usage: fetch_with_playwright.js URL OUTPUT_PATH");
process.exit(1);
}
const { chromium } = loadPlaywright();
const headless = process.env.PLAYWRIGHT_HEADLESS !== "0";
const profileDir = process.env.PLAYWRIGHT_PROFILE_DIR;
const storageState = process.env.PLAYWRIGHT_STORAGE_STATE;
const referer = process.env.PLAYWRIGHT_REFERER;
const cdpEndpoint = process.env.PLAYWRIGHT_CDP_ENDPOINT;
const fetchTimeoutMs = Number(process.env.PLAYWRIGHT_FETCH_TIMEOUT_MS || 20000);
let browser = null;
let context = null;
let page = null;
let shouldCloseBrowser = false;
let shouldCloseContext = false;
let shouldClosePage = false;
if (cdpEndpoint) {
browser = await chromium.connectOverCDP(cdpEndpoint);
shouldCloseBrowser = true;
context = browser.contexts()[0];
if (!context) {
throw new Error(`No browser context available via CDP endpoint ${cdpEndpoint}`);
}
const origin = new URL(targetUrl).origin + "/";
page = findKomodoPage(context, origin);
if (!page) {
page = await context.newPage();
shouldClosePage = true;
}
} else if (profileDir) {
context = await chromium.launchPersistentContext(profileDir, {
...launchOptions(headless),
userAgent: "Mozilla/5.0",
});
shouldCloseContext = true;
page = context.pages()[0] || await context.newPage();
} else {
browser = await chromium.launch(launchOptions(headless));
shouldCloseBrowser = true;
context = await browser.newContext({
userAgent: "Mozilla/5.0",
storageState: storageState || undefined,
extraHTTPHeaders: referer ? { Referer: referer } : undefined,
});
shouldCloseContext = true;
page = await context.newPage();
}
try {
const origin = new URL(targetUrl).origin + "/";
if (!page.url().startsWith(origin)) {
await page.goto(origin, { waitUntil: "domcontentloaded", timeout: 45000 });
}
const body = await page.evaluate(async ({ url, timeoutMs }) => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(url, { credentials: "include", signal: controller.signal });
clearTimeout(timeout);
return await response.text();
}, { url: targetUrl, timeoutMs: fetchTimeoutMs });
fs.writeFileSync(outputPath, body);
} finally {
if (shouldClosePage && page && !page.isClosed()) {
await page.close();
}
if (shouldCloseContext && context) {
await context.close();
}
if (shouldCloseBrowser && browser) {
await browser.close();
}
}
}
main().catch((err) => {
console.error(err && err.stack ? err.stack : String(err));
process.exit(1);
});
+60
View File
@@ -0,0 +1,60 @@
#!/bin/sh
set -eu
if [ "$#" -lt 1 ]; then
echo "usage: $0 YYYY-MM-DD [base_dir]" >&2
exit 1
fi
RUN_DATE="$1"
BASE_DIR="${2:-$HOME/steam_hardware_watch}"
RUN_DIR="$BASE_DIR/$RUN_DATE"
mkdir -p "$RUN_DIR/api" "$RUN_DIR/assets" "$RUN_DIR/frames" "$RUN_DIR/reports"
RUN_NOTE_DIR="$(CDPATH= cd -- "$(dirname "$0")/../status/runs" && pwd)"
RUN_NOTE="$RUN_NOTE_DIR/$RUN_DATE.md"
if [ ! -f "$RUN_NOTE" ]; then
cat >"$RUN_NOTE" <<EOF
# Run Note: $RUN_DATE
## Summary
-
## Source Checks
### Komodo
-
### SteamDB
-
### SteamTracking / GameTracking
-
### Valve Support / CDN
-
### Customs / Regulatory
-
## Effect On Key Questions
- Price:
- Release date:
- Same-time launch:
## Next Checks
-
EOF
fi
printf 'RUN_DIR=%s\nRUN_NOTE=%s\n' "$RUN_DIR" "$RUN_NOTE"
+84
View File
@@ -0,0 +1,84 @@
#!/bin/sh
set -eu
if [ "$#" -lt 1 ]; then
echo "usage: $0 YYYY-MM-DD [base_dir] [tracking_dir]" >&2
exit 1
fi
RUN_DATE="$1"
BASE_DIR="${2:-$HOME/steam_hardware_watch}"
TRACKING_DIR="${3:-/tmp/SteamTracking-master}"
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
REPO_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)"
KOMODO_ENV_FILE="${KOMODO_ENV_FILE:-$REPO_DIR/.local/komodo-env.sh}"
cleanup() {
if [ "${KOMODO_KEEP_BROWSER_OPEN:-0}" = "1" ]; then
return 0
fi
if [ -f "$KOMODO_ENV_FILE" ] && [ -x "$SCRIPT_DIR/close_komodo.sh" ]; then
"$SCRIPT_DIR/close_komodo.sh" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT INT TERM
INIT_OUTPUT="$("$SCRIPT_DIR/init_run.sh" "$RUN_DATE" "$BASE_DIR")"
RUN_DIR="$(printf '%s\n' "$INIT_OUTPUT" | awk -F= '/^RUN_DIR=/{print $2}')"
RUN_NOTE="$(printf '%s\n' "$INIT_OUTPUT" | awk -F= '/^RUN_NOTE=/{print $2}')"
if [ -z "$RUN_DIR" ] || [ -z "$RUN_NOTE" ]; then
echo "failed to initialize run folder" >&2
exit 1
fi
printf '%s\n' "Run dir: $RUN_DIR"
printf '%s\n' "Run note: $RUN_NOTE"
"$SCRIPT_DIR/check_komodo.sh" "$RUN_DIR"
"$SCRIPT_DIR/check_steamdb.sh" "$RUN_DIR"
"$SCRIPT_DIR/check_steamtracking.sh" "$RUN_DIR" "$TRACKING_DIR"
"$SCRIPT_DIR/check_valve_endpoints.sh" "$RUN_DIR"
python3 "$SCRIPT_DIR/save_visual_assets.py" --run-dir "$RUN_DIR" --base-dir "$BASE_DIR"
PREVIOUS_DIR=""
for candidate in $(find "$BASE_DIR" -mindepth 1 -maxdepth 1 -type d -name '????-??-??' | sort); do
if [ "$candidate" = "$RUN_DIR" ]; then
continue
fi
case "$(basename "$candidate")" in
"$RUN_DATE") ;;
*)
if [ "$(basename "$candidate")" \< "$RUN_DATE" ]; then
PREVIOUS_DIR="$candidate"
fi
;;
esac
done
if [ -n "$PREVIOUS_DIR" ]; then
COMPARE_OUT="$RUN_DIR/reports/compare-vs-$(basename "$PREVIOUS_DIR").md"
python3 "$SCRIPT_DIR/compare_runs.py" \
--previous "$PREVIOUS_DIR" \
--current "$RUN_DIR" \
--output "$COMPARE_OUT"
printf '%s\n' "Comparison report: $COMPARE_OUT"
else
printf '%s\n' "No previous run folder found under $BASE_DIR"
fi
STATUS_DRAFT_OUT="$RUN_DIR/reports/status-draft.md"
python3 "$SCRIPT_DIR/draft_status_update.py" \
--run-dir "$RUN_DIR" \
--output "$STATUS_DRAFT_OUT"
printf '%s\n' "Status draft: $STATUS_DRAFT_OUT"
RUN_SUMMARY_OUT="$RUN_DIR/reports/run-summary.md"
python3 "$SCRIPT_DIR/write_run_summary.py" \
--run-dir "$RUN_DIR" \
--output "$RUN_SUMMARY_OUT"
printf '%s\n' "Run summary: $RUN_SUMMARY_OUT"
printf '%s\n' "Run complete."
+331
View File
@@ -0,0 +1,331 @@
#!/usr/bin/env python3
import argparse
import json
import urllib.error
import urllib.request
from pathlib import Path
from urllib.parse import urlparse
VISUAL_EXTS = {
".png",
".jpg",
".jpeg",
".webp",
".avif",
".svg",
".gif",
".mp4",
".webm",
".mov",
".m4v",
}
def is_visual_url(url: str) -> bool:
path = urlparse(url).path.lower()
return any(path.endswith(ext) for ext in VISUAL_EXTS)
def walk_json(value):
if isinstance(value, dict):
yield value
for child in value.values():
yield from walk_json(child)
elif isinstance(value, list):
for item in value:
yield from walk_json(item)
def collect_from_json(path: Path):
if path.name in {"media-spec-search.json", "media-manual-search.json", "media-video.json"}:
return []
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return []
hits = []
for obj in walk_json(data):
if not isinstance(obj, dict):
continue
url = obj.get("source_url")
mime = obj.get("mime_type", "")
if isinstance(url, str) and url.startswith("http"):
if mime.startswith("image/") or mime.startswith("video/") or is_visual_url(url):
hits.append((url, mime, str(path)))
return hits
def collect_from_seed_reports(base_dir: Path, current_run_dir: Path):
if not base_dir.exists():
return []
hits = []
patterns = (
("discovered-visual-assets.tsv", 0, 2),
("retrieved-visual-assets.tsv", 0, 2),
("blocked-visual-assets.tsv", None, 4),
("downloaded-visual-assets.tsv", 0, 2),
)
for report_name, mime_index, url_index in patterns:
for report in sorted(base_dir.glob(f"????-??-??/reports/{report_name}")):
run_dir = report.parent.parent
if run_dir == current_run_dir:
continue
for raw_line in report.read_text(encoding="utf-8", errors="ignore").splitlines():
line = raw_line.strip()
if not line:
continue
parts = line.split("\t")
if len(parts) <= url_index:
continue
url = parts[url_index]
if not isinstance(url, str) or not url.startswith("http") or not is_visual_url(url):
continue
mime = parts[mime_index] if mime_index is not None and len(parts) > mime_index else ""
hits.append((url, mime, f"seed:{run_dir.name}:{report_name}"))
return hits
def destination_for(base_dir: Path, url: str) -> Path:
parsed = urlparse(url)
rel = (parsed.netloc + parsed.path).lstrip("/")
return base_dir / rel
def is_protected_komodo_asset(url: str) -> bool:
parsed = urlparse(url)
host = parsed.netloc.lower()
path = parsed.path.lower()
if "komodostation.com" not in host:
return False
return path.endswith((".mp4", ".webm", ".mov", ".m4v", ".avif"))
def detect_format(data: bytes) -> str:
head = data[:512]
lower = head.lower()
if head.startswith(b"\x89PNG\r\n\x1a\n"):
return "png"
if head.startswith(b"\xff\xd8\xff"):
return "jpeg"
if head.startswith((b"GIF87a", b"GIF89a")):
return "gif"
if head.startswith(b"RIFF") and b"WEBP" in head[:16]:
return "webp"
if head.startswith(b"\x1a\x45\xdf\xa3"):
return "webm"
if b"ftyp" in head[:32]:
if b"avif" in head[:32] or b"avis" in head[:32]:
return "avif"
return "mp4"
if b"<svg" in lower:
return "svg"
if lower.startswith(b"<!doctype html") or lower.startswith(b"<html"):
return "html"
return "unknown"
def expected_family(url: str) -> str:
path = urlparse(url).path.lower()
if path.endswith(".png"):
return "png"
if path.endswith((".jpg", ".jpeg")):
return "jpeg"
if path.endswith(".gif"):
return "gif"
if path.endswith(".webp"):
return "webp"
if path.endswith(".avif"):
return "avif"
if path.endswith(".svg"):
return "svg"
if path.endswith(".webm"):
return "webm"
if path.endswith((".mp4", ".mov", ".m4v")):
return "mp4"
return "unknown"
def is_valid_download(url: str, content_type: str, data: bytes):
detected = detect_format(data)
expected = expected_family(url)
ctype = (content_type or "").lower()
if detected == "html":
return False, detected, expected
if ctype.startswith("text/html"):
return False, detected, expected
if expected != "unknown" and detected != expected:
return False, detected, expected
if detected == "unknown":
return False, detected, expected
return True, detected, expected
def download(url: str, dest: Path):
dest.parent.mkdir(parents=True, exist_ok=True)
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=15) as response:
data = response.read()
status = getattr(response, "status", 200)
content_type = response.headers.get_content_type()
ok, detected, expected = is_valid_download(url, content_type, data)
if not ok:
return {
"ok": False,
"status": str(status),
"content_type": content_type,
"detected": detected,
"expected": expected,
}
dest.write_bytes(data)
return {
"ok": True,
"status": str(status),
"content_type": content_type,
"detected": detected,
"expected": expected,
}
def main():
parser = argparse.ArgumentParser(description="Save discovered visual assets from a run directory.")
parser.add_argument("--run-dir", required=True)
parser.add_argument("--base-dir", help="Optional base directory containing prior dated runs to seed discovery from")
args = parser.parse_args()
run_dir = Path(args.run_dir)
base_dir = Path(args.base_dir) if args.base_dir else run_dir.parent
api_dir = run_dir / "api"
asset_dir = run_dir / "assets" / "discovered"
report_dir = run_dir / "reports"
report_dir.mkdir(parents=True, exist_ok=True)
asset_dir.mkdir(parents=True, exist_ok=True)
hits = []
for path in (api_dir / "komodo").rglob("*.json"):
hits.extend(collect_from_json(path))
hits.extend(collect_from_seed_reports(base_dir, run_dir))
unique = {}
for url, mime, source in hits:
unique.setdefault(url, {"mime": mime, "sources": set()})
if mime and not unique[url]["mime"]:
unique[url]["mime"] = mime
unique[url]["sources"].add(source)
discovered_rows = []
retrieved_rows = []
error_rows = []
for url in sorted(unique):
discovered_rows.append(
"\t".join(
[
unique[url]["mime"],
expected_family(url),
url,
",".join(sorted(unique[url]["sources"])),
]
)
)
dest = destination_for(asset_dir, url)
if dest.exists():
data = dest.read_bytes()[:512]
ok, detected, expected = is_valid_download(url, unique[url]["mime"], data)
if not ok:
dest.unlink(missing_ok=True)
result = None
if not dest.exists():
if is_protected_komodo_asset(url):
result = {
"ok": False,
"status": "session_required",
"content_type": unique[url]["mime"],
"detected": "protected_komodo_asset",
"expected": expected_family(url),
}
else:
try:
result = download(url, dest)
except urllib.error.HTTPError as err:
result = {
"ok": False,
"status": str(err.code),
"content_type": getattr(err, "headers", {}).get_content_type() if getattr(err, "headers", None) else "",
"detected": "http_error",
"expected": expected_family(url),
}
except Exception as err:
result = {
"ok": False,
"status": "error",
"content_type": "",
"detected": type(err).__name__,
"expected": expected_family(url),
}
else:
result = {
"ok": True,
"status": "cached",
"content_type": unique[url]["mime"],
"detected": detect_format(dest.read_bytes()[:512]),
"expected": expected_family(url),
}
if result["ok"]:
retrieved_rows.append(
"\t".join(
[
unique[url]["mime"],
result["detected"],
url,
str(dest),
",".join(sorted(unique[url]["sources"])),
]
)
)
else:
dest.unlink(missing_ok=True)
error_rows.append(
"\t".join(
[
result["status"],
result["content_type"],
result["detected"],
result["expected"],
url,
",".join(sorted(unique[url]["sources"])),
]
)
)
(report_dir / "discovered-visual-assets.tsv").write_text(
"\n".join(discovered_rows) + ("\n" if discovered_rows else ""),
encoding="utf-8",
)
(report_dir / "retrieved-visual-assets.tsv").write_text(
"\n".join(retrieved_rows) + ("\n" if retrieved_rows else ""),
encoding="utf-8",
)
(report_dir / "blocked-visual-assets.tsv").write_text(
"\n".join(error_rows) + ("\n" if error_rows else ""),
encoding="utf-8",
)
manual_urls = sorted({row.split("\t")[4] for row in error_rows if len(row.split("\t")) > 4})
(report_dir / "manual-asset-urls.txt").write_text(
"\n".join(manual_urls) + ("\n" if manual_urls else ""),
encoding="utf-8",
)
# Backward-compatible alias for earlier script versions.
(report_dir / "downloaded-visual-assets.tsv").write_text(
"\n".join(retrieved_rows) + ("\n" if retrieved_rows else ""),
encoding="utf-8",
)
if __name__ == "__main__":
main()
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
import argparse
from pathlib import Path
MAX_LINE_LEN = 220
FILTER_TERMS = (
"controller",
"machine",
"frame",
"triton",
"puck",
"ibex",
"unboxing",
"coming",
"2026",
"pair",
"firmware",
)
REJECT_TERMS = (
"application_config",
"data-config",
"&quot;",
"localized_",
)
def read_lines(path: Path):
if not path.exists():
return []
return [line.rstrip("\n") for line in path.read_text(encoding="utf-8", errors="ignore").splitlines() if line.strip()]
def count(path: Path):
return len(read_lines(path))
def first(path: Path, n: int):
return read_lines(path)[:n]
def concise(line: str):
trimmed = " ".join(line.split())
if len(trimmed) > MAX_LINE_LEN:
return trimmed[: MAX_LINE_LEN - 3] + "..."
return trimmed
def filtered_first(path: Path, n: int):
selected = []
for line in read_lines(path):
lowered = line.lower()
if any(term in lowered for term in REJECT_TERMS):
continue
if path.name == "valve-key-lines.txt" and not any(term in lowered for term in FILTER_TERMS):
continue
selected.append(concise(line))
if len(selected) >= n:
break
return selected
def blocked_asset_urls(path: Path, n: int):
urls = []
if not path.exists():
return urls
for line in read_lines(path):
parts = line.split("\t")
if len(parts) > 4 and parts[4].startswith("http"):
urls.append(parts[4])
if len(urls) >= n:
break
return urls
def count_block_reason(path: Path, reason: str):
total = 0
if not path.exists():
return total
for line in read_lines(path):
parts = line.split("\t")
if parts and parts[0] == reason:
total += 1
return total
def build(run_dir: Path):
reports = run_dir / "reports"
compare = sorted(reports.glob("compare-vs-*.md"))
compare_file = compare[-1] if compare else None
discovered = count(reports / "discovered-visual-assets.tsv")
retrieved = count(reports / "retrieved-visual-assets.tsv")
blocked = count(reports / "blocked-visual-assets.tsv")
session_required = count_block_reason(reports / "blocked-visual-assets.tsv", "session_required")
lines = [
f"# Run Summary: {run_dir.name}",
"",
f"Artifacts: `{run_dir}`",
"",
"## At A Glance",
"",
f"- Komodo blocked: `{'yes' if count(reports / 'komodo-errors.txt') else 'no'}`",
f"- SteamDB blocked: `{'yes' if count(reports / 'steamdb-errors.txt') else 'no'}`",
f"- Valve blocked: `{'yes' if count(reports / 'valve-errors.txt') else 'no'}`",
f"- Discovered visual assets: `{discovered}`",
f"- Retrieved visual assets: `{retrieved}`",
f"- Blocked visual assets: `{blocked}`",
f"- Blocked assets requiring trusted session: `{session_required}`",
"",
"## Most Relevant Outputs",
"",
f"- Status draft: `{reports / 'status-draft.md'}`",
f"- Discovered assets: `{reports / 'discovered-visual-assets.tsv'}`",
f"- Retrieved assets: `{reports / 'retrieved-visual-assets.tsv'}`",
f"- Blocked assets: `{reports / 'blocked-visual-assets.tsv'}`",
f"- Manual retry URLs: `{reports / 'manual-asset-urls.txt'}`",
]
if compare_file:
lines.append(f"- Comparison report: `{compare_file}`")
lines.extend(["", "## Notable Lines", ""])
notable = []
notable.extend([f"- SteamDB: `{line}`" for line in filtered_first(reports / "steamdb-key-lines.txt", 5)])
notable.extend([f"- SteamTracking: `{line}`" for line in filtered_first(reports / "steamtracking-pairing-focus.txt", 5)])
notable.extend([f"- Valve: `{line}`" for line in filtered_first(reports / "valve-key-lines.txt", 5)])
notable.extend([f"- Asset blocked: `{line}`" for line in filtered_first(reports / "blocked-visual-assets.tsv", 5)])
if notable:
lines.extend(notable)
else:
lines.append("- No notable lines were extracted.")
manual_urls = blocked_asset_urls(reports / "blocked-visual-assets.tsv", 10)
lines.extend(["", "## Manual Follow-Up URLs", ""])
if manual_urls:
lines.extend([f"- `{url}`" for url in manual_urls])
else:
lines.append("- No blocked asset URLs were recorded in this run.")
lines.extend(["", "## Next Human Step", "", "- Review this summary first, then open the detailed reports only if something changed or looks important.", ""])
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="Write a concise human-readable run summary.")
parser.add_argument("--run-dir", required=True)
parser.add_argument("--output", help="Optional output path")
args = parser.parse_args()
run_dir = Path(args.run_dir)
output = Path(args.output) if args.output else run_dir / "reports" / "run-summary.md"
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(build(run_dir), encoding="utf-8")
if __name__ == "__main__":
main()
+52
View File
@@ -0,0 +1,52 @@
# Steam Hardware Watch Status
Last updated: `2026-04-24`
## Best Current Answer
- `Price`: no confirmed public price leak for the new Steam Controller.
- `Release date`: no exact public launch date found.
- `Same-time launch`: not supported by the current evidence. A shared rollout window remains plausible, but the controller looks further along than Steam Machine or Steam Frame.
## Source Snapshot
### Komodo
- `Steam Controller`, `Steam Machine`, and `Steam Frame` are all real published WooCommerce products in a held state:
- `publish`
- `product-type-simple`
- `outofstock`
- `wishlist-only`
- On `2026-04-24`, Komodo bulk-updated product records for all three hardware lines across currencies.
- Also on `2026-04-24`, Komodo exposed a fresh controller-only section and media rollout with banner, videos, feature art, controls imagery, and a spec image.
- No comparable fresh Machine or Frame section and media rollout was found.
### SteamDB
- The strongest launch-adjacent signal remains the hidden `steam_controller_unboxing_2026` asset first seen on `2026-04-20`.
### SteamTracking / GameTracking
- Valve’s client strings already include controller onboarding, firmware update flow, puck handling, and pairing logic for the new controller ecosystem.
### Valve Support / CDN
- No controller price or date leak has been confirmed from support pages or discoverable docs in the seeded baseline.
### Customs / Regulatory
- Shipment and filing checks remain secondary confirmation sources. No direct price or launch-date confirmation is seeded here.
## Open Questions
- When will Valve publish price?
- Will preorder and ship dates differ?
- Will Steam Machine and Steam Frame share announcement timing with the controller, or trail it?
- When do comparable fresh media sets appear for Machine and Frame, if at all?
## Next Checks
- Recheck Komodo `sections` and `media` for Machine and Frame.
- Recheck SteamDB controller app, package, and hidden media activity.
- Recheck Valve support or CDN assets for manuals, safety docs, and store-adjacent media.
- Run customs or regulatory checks if a new primary-source signal appears.
+8
View File
@@ -0,0 +1,8 @@
{"date_checked":"2026-04-24","source":"komodo","entity":"steam-controller","claim":"Steam Controller JPY is a real published WooCommerce product record in a held launch state.","evidence_type":"product_metadata","confidence":"high","ref":"https://komodostation.com/wp-json/wp/v2/product/413763","notes":"class_list includes product-type-simple, outofstock, product_tag-wishlist-only"}
{"date_checked":"2026-04-24","source":"komodo","entity":"steam-machine","claim":"Steam Machine JPY is a real published WooCommerce product record in the same held launch state as the controller.","evidence_type":"product_metadata","confidence":"high","ref":"https://komodostation.com/wp-json/wp/v2/product/413772","notes":"featured_media 413703; class_list matches controller state"}
{"date_checked":"2026-04-24","source":"komodo","entity":"steam-frame","claim":"Steam Frame JPY is a real published WooCommerce product record in the same held launch state as the controller.","evidence_type":"product_metadata","confidence":"high","ref":"https://komodostation.com/wp-json/wp/v2/product/413776","notes":"featured_media 413708; class_list matches controller state"}
{"date_checked":"2026-04-24","source":"komodo","entity":"steam-controller","claim":"Komodo published a fresh controller-only section and media rollout on 2026-04-24.","evidence_type":"sections_media_rollout","confidence":"high","ref":"/Users/sean.betts/komodo_mining_2026-04-24/reports/findings.md","notes":"sections 433306, 433326, 433356, 433376, 433416, 433434, 433470, 433484"}
{"date_checked":"2026-04-24","source":"komodo","entity":"steam-machine,steam-frame","claim":"No matching fresh Machine or Frame section and media rollout was found in the same public Komodo backend paths.","evidence_type":"comparative_absence","confidence":"medium","ref":"/Users/sean.betts/komodo_mining_2026-04-24/api/sections-machine-browser.json","notes":"Machine only had older wishlist section 413808; Frame only had older wishlist section 413804"}
{"date_checked":"2026-04-24","source":"steamdb","entity":"steam-controller","claim":"A hidden Steam Controller unboxing video asset exists and is the strongest late-stage public launch signal in the seeded baseline.","evidence_type":"hidden_media_app","confidence":"high","ref":"https://steamdb.info/app/4653940/","notes":"known internally as steam_controller_unboxing_2026 from prior research"}
{"date_checked":"2026-04-24","source":"steamtracking","entity":"steam-controller","claim":"Valve client assets already contain controller onboarding, firmware, puck, and pairing flows for the new controller ecosystem.","evidence_type":"client_strings","confidence":"medium","ref":"/tmp/SteamTracking-master/Protobufs/webuimessages_steaminput.proto","notes":"Triton, Puck, and pairing/OOBE flows present in seeded local dump"}
{"date_checked":"2026-04-24","source":"analysis","entity":"steam-controller,steam-machine,steam-frame","claim":"Current evidence supports a controller-ahead rollout more than a same-day three-product launch.","evidence_type":"cross_source_inference","confidence":"medium","ref":"/Users/sean.betts/.codex/skills/steam-hardware-watch/status/runs/2026-04-24.md","notes":"driven by controller-only Komodo rollout plus SteamDB unboxing signal"}