diff --git a/plugins/clone-app/skills/clone-app/scripts/check-appstore.py b/plugins/clone-app/skills/clone-app/scripts/check-appstore.py index 435c938..d7a2afa 100644 --- a/plugins/clone-app/skills/clone-app/scripts/check-appstore.py +++ b/plugins/clone-app/skills/clone-app/scripts/check-appstore.py @@ -2,14 +2,33 @@ """Best-effort check whether an iOS App Store equivalent exists, via the public iTunes Search API. There is no reliable Android-package → App-Store-ID mapping, so we search by term (app title) and return the top matches.""" -import sys, json, argparse, urllib.request, urllib.parse +import sys, json, argparse, urllib.request, urllib.parse, ssl, subprocess, shutil + +UA = "Mozilla/5.0" + +def _http_get(url): + """GET a URL; on an SSL trust failure (macOS system Python ships without a + CA bundle) fall back to the system `curl`. Stdlib-only, no pip.""" + req = urllib.request.Request(url, headers={"User-Agent": UA}) + try: + with urllib.request.urlopen(req, timeout=30) as r: + return r.read().decode("utf-8", "replace") + except urllib.error.URLError as e: + if not isinstance(e.reason, ssl.SSLError): + raise + if not shutil.which("curl"): + raise + out = subprocess.run( + ["curl", "-sL", "--fail", "-A", UA, url], + capture_output=True, timeout=60) + if out.returncode != 0: + raise + return out.stdout.decode("utf-8", "replace") def fetch(term): q = urllib.parse.urlencode({"term": term, "entity": "software", "limit": 5}) url = f"https://itunes.apple.com/search?{q}" - req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) - with urllib.request.urlopen(req, timeout=30) as r: - return json.loads(r.read().decode("utf-8", "replace")) + return json.loads(_http_get(url)) def shape(data): results = [] diff --git a/plugins/clone-app/skills/clone-app/scripts/resolve-re-scripts.sh b/plugins/clone-app/skills/clone-app/scripts/resolve-re-scripts.sh index 276e8fe..f1cab19 100755 --- a/plugins/clone-app/skills/clone-app/scripts/resolve-re-scripts.sh +++ b/plugins/clone-app/skills/clone-app/scripts/resolve-re-scripts.sh @@ -11,11 +11,43 @@ else plugin_root="$(cd "$script_dir/../../.." && pwd)" fi -# Sibling RE plugin lives next to clone-app under plugins/ -re_scripts="$(cd "$plugin_root/.." 2>/dev/null && pwd)/android-reverse-engineering/skills/android-reverse-engineering/scripts" +# Locate the RE plugin's scripts dir. The tail is always +# .../android-reverse-engineering/skills/android-reverse-engineering/scripts, +# but the parent layout differs by install: +# - repo / dev checkout: plugins/android-reverse-engineering/skills/... (flat sibling) +# - plugin cache: cache//android-reverse-engineering//skills/... +# so we probe a list of candidate globs and take the first match (highest +# version when several exist). +tail="skills/android-reverse-engineering/scripts" +# `|| true`: under `set -e`, a bare `var=$(cmd)` whose cmd fails (e.g. the dir +# doesn't exist) aborts the script. We want to fall through to the cache globs. +parent="$(cd "$plugin_root/.." 2>/dev/null && pwd || true)" -if [[ ! -d "$re_scripts" ]]; then - echo "ERROR: android-reverse-engineering scripts not found at: $re_scripts" >&2 +candidates=() +# 1. flat sibling next to clone-app (repo/dev layout) +[[ -n "$parent" ]] && candidates+=("$parent/android-reverse-engineering/$tail") +# 2. versioned sibling under the same marketplace dir (cache layout) +[[ -n "$parent" ]] && candidates+=("$parent"/android-reverse-engineering/*/"$tail") +# 3. anywhere under the Claude plugin cache (any marketplace, any version) +cache_root="${CLAUDE_PLUGIN_CACHE:-$HOME/.claude/plugins/cache}" +candidates+=("$cache_root"/*/android-reverse-engineering/*/"$tail") +candidates+=("$cache_root"/*/android-reverse-engineering/"$tail") + +re_scripts="" +for cand in "${candidates[@]}"; do + # glob entries that didn't match stay literal (with '*'); skip those. + [[ "$cand" == *"*"* ]] && continue + [[ -d "$cand" ]] || continue + # Prefer the highest version: keep the lexically-greatest matching path. + if [[ -z "$re_scripts" || "$cand" > "$re_scripts" ]]; then + re_scripts="$cand" + fi +done + +if [[ -z "$re_scripts" || ! -d "$re_scripts" ]]; then + echo "ERROR: android-reverse-engineering scripts not found. Looked for:" >&2 + echo " - $parent/android-reverse-engineering/$tail (sibling)" >&2 + echo " - $cache_root/*/android-reverse-engineering/*/$tail (cache)" >&2 echo "Install it: /plugin install android-reverse-engineering@android-reverse-engineering-skill" >&2 exit 1 fi diff --git a/plugins/clone-app/skills/clone-app/scripts/scrape-play-store.py b/plugins/clone-app/skills/clone-app/scripts/scrape-play-store.py index 1646760..0f8c5ba 100644 --- a/plugins/clone-app/skills/clone-app/scripts/scrape-play-store.py +++ b/plugins/clone-app/skills/clone-app/scripts/scrape-play-store.py @@ -4,24 +4,45 @@ Primary source is the embedded ld+json SoftwareApplication block (stable). Falls back to light regex for installs/updated which aren't always in ld+json. """ -import sys, json, re, argparse, urllib.request +import sys, json, re, argparse, urllib.request, ssl, subprocess, shutil KEYS = ["package", "title", "rating", "rating_count", "installs", "category", "developer", "updated", "source"] +UA = "Mozilla/5.0" + +def _http_get(url): + """GET a URL as text. Try urllib first; on an SSL trust failure (common on + macOS' system Python, which ships without a CA bundle) fall back to the + system `curl`, which uses the OS trust store. Stdlib-only, no pip.""" + req = urllib.request.Request(url, headers={"User-Agent": UA}) + try: + with urllib.request.urlopen(req, timeout=30) as r: + return r.read().decode("utf-8", "replace") + except urllib.error.URLError as e: + if not isinstance(e.reason, ssl.SSLError): + raise + if not shutil.which("curl"): + raise + out = subprocess.run( + ["curl", "-sL", "--fail", "-A", UA, url], + capture_output=True, timeout=60) + if out.returncode != 0: + raise + return out.stdout.decode("utf-8", "replace") + def fetch(package): url = f"https://play.google.com/store/apps/details?id={package}&hl=en&gl=US" - req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) - with urllib.request.urlopen(req, timeout=30) as r: - return r.read().decode("utf-8", "replace") + return _http_get(url) def parse(html, package): out = {k: None for k in KEYS} out["package"] = package out["source"] = "google-play" - # ld+json SoftwareApplication block - for m in re.finditer(r'', + # ld+json SoftwareApplication block. The ', html, re.DOTALL): try: data = json.loads(m.group(1).strip()) @@ -42,13 +63,19 @@ def parse(html, package): except Exception: pass break - # installs (e.g. "1,000,000+") - m = re.search(r'([\d,]+\+)\s*\s*Downloads', html) + # installs (e.g. "1,000,000+"). Play wraps the count and the "Downloads" + # label in their own elements; the label may be a or a
. + m = re.search(r'([\d,]+\+)\s*]+>\s*<[^>]*>\s*Downloads', html) + if not m: + m = re.search(r'([\d,]+\+)\s*\s*Downloads', html) if m: out["installs"] = m.group(1) - # updated date (e.g. "Updated onJun 1, 2026") - m = re.search(r'Updated on\s*([^<]+)', html) + # updated date (e.g. "Updated on
Jun 1, 2026
" — the + # label and value live in adjacent elements that may be or
). + m = re.search(r'Updated on\s*]+>\s*<[^>]*>([^<]+)]+>', html) + if not m: + m = re.search(r'Updated on\s*([^<]+)', html) if m: out["updated"] = m.group(1).strip() diff --git a/plugins/clone-app/tests/fixtures/play-sample.html b/plugins/clone-app/tests/fixtures/play-sample.html index 309662c..48dc41c 100644 --- a/plugins/clone-app/tests/fixtures/play-sample.html +++ b/plugins/clone-app/tests/fixtures/play-sample.html @@ -1,11 +1,11 @@ Example App - Apps on Google Play - -
Updated onJun 1, 2026
-
1,000,000+Downloads
+
Updated on
Jun 1, 2026
+
1,000,000+
Downloads
diff --git a/plugins/clone-app/tests/test-resolve-re-scripts.sh b/plugins/clone-app/tests/test-resolve-re-scripts.sh index 75cfe12..c9d4dce 100644 --- a/plugins/clone-app/tests/test-resolve-re-scripts.sh +++ b/plugins/clone-app/tests/test-resolve-re-scripts.sh @@ -12,10 +12,20 @@ check "ends with scripts dir" "android-reverse-engineering/skills/android-revers check "decompile.sh exists under resolved dir" "yes" \ "$([[ -f "$out/decompile.sh" ]] && echo yes || echo no)" -# When CLAUDE_PLUGIN_ROOT points somewhere with no sibling RE plugin → exit 1 +# When the sibling is absent but the RE plugin lives in the Claude plugin cache +# (the real install layout: cache//android-reverse-engineering//...), +# the resolver discovers it there. Point both env vars at temp dirs: the cache +# var at a fake versioned layout, the root at a dir with no sibling. tmp="$(mktemp -d)" -out2="$(CLAUDE_PLUGIN_ROOT="$tmp/clone-app" bash "$SCRIPT" 2>/dev/null)"; rc2=$? -check "exit 1 when RE missing" "1" "$rc2" +fake="$tmp/cache/some-marketplace/android-reverse-engineering/9.9.9/skills/android-reverse-engineering/scripts" +mkdir -p "$fake"; : > "$fake/decompile.sh" +out2="$(CLAUDE_PLUGIN_ROOT="$tmp/clone-app" CLAUDE_PLUGIN_CACHE="$tmp/cache" bash "$SCRIPT" 2>/dev/null)"; rc2=$? +check "exit 0 when RE only in cache" "0" "$rc2" +check "resolves the cached scripts dir" "$fake" "$out2" + +# When neither a sibling nor any cache entry exists → exit 1 +out3="$(CLAUDE_PLUGIN_ROOT="$tmp/clone-app" CLAUDE_PLUGIN_CACHE="$tmp/empty" bash "$SCRIPT" 2>/dev/null)"; rc3=$? +check "exit 1 when RE missing everywhere" "1" "$rc3" rm -rf "$tmp" exit $fail diff --git a/plugins/clone-app/tests/test-scrape-play-store.py b/plugins/clone-app/tests/test-scrape-play-store.py index 80f37c5..33bc010 100644 --- a/plugins/clone-app/tests/test-scrape-play-store.py +++ b/plugins/clone-app/tests/test-scrape-play-store.py @@ -23,6 +23,7 @@ def main(): check("developer", d["developer"] == "Example Studio") check("category", d["category"] == "GAME_PUZZLE") check("installs", d["installs"] == "1,000,000+") + check("updated", d["updated"] == "Jun 1, 2026") check("source", d["source"] == "google-play") # all expected keys present even if null for k in ["package","title","rating","rating_count","installs","category","developer","updated","source"]: