feat(clone-app): enhance HTTP request handling and update test cases for Play Store scraping

This commit is contained in:
fatih.bulut 2026-06-21 20:12:31 +03:00
parent b34632ef3b
commit 0864551bde
6 changed files with 114 additions and 25 deletions

View File

@ -2,14 +2,33 @@
"""Best-effort check whether an iOS App Store equivalent exists, via the """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 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.""" 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): def fetch(term):
q = urllib.parse.urlencode({"term": term, "entity": "software", "limit": 5}) q = urllib.parse.urlencode({"term": term, "entity": "software", "limit": 5})
url = f"https://itunes.apple.com/search?{q}" url = f"https://itunes.apple.com/search?{q}"
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) return json.loads(_http_get(url))
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read().decode("utf-8", "replace"))
def shape(data): def shape(data):
results = [] results = []

View File

@ -11,11 +11,43 @@ else
plugin_root="$(cd "$script_dir/../../.." && pwd)" plugin_root="$(cd "$script_dir/../../.." && pwd)"
fi fi
# Sibling RE plugin lives next to clone-app under plugins/ # Locate the RE plugin's scripts dir. The tail is always
re_scripts="$(cd "$plugin_root/.." 2>/dev/null && pwd)/android-reverse-engineering/skills/android-reverse-engineering/scripts" # .../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/<marketplace>/android-reverse-engineering/<version>/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 candidates=()
echo "ERROR: android-reverse-engineering scripts not found at: $re_scripts" >&2 # 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 echo "Install it: /plugin install android-reverse-engineering@android-reverse-engineering-skill" >&2
exit 1 exit 1
fi fi

View File

@ -4,24 +4,45 @@
Primary source is the embedded ld+json SoftwareApplication block (stable). 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. 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", KEYS = ["package", "title", "rating", "rating_count", "installs",
"category", "developer", "updated", "source"] "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): def fetch(package):
url = f"https://play.google.com/store/apps/details?id={package}&hl=en&gl=US" 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"}) return _http_get(url)
with urllib.request.urlopen(req, timeout=30) as r:
return r.read().decode("utf-8", "replace")
def parse(html, package): def parse(html, package):
out = {k: None for k in KEYS} out = {k: None for k in KEYS}
out["package"] = package out["package"] = package
out["source"] = "google-play" out["source"] = "google-play"
# ld+json SoftwareApplication block # ld+json SoftwareApplication block. The <script> tag may carry extra
for m in re.finditer(r'<script type="application/ld\+json">(.*?)</script>', # attributes (e.g. a CSP nonce="..."), so match any attributes up to '>'.
for m in re.finditer(r'<script type="application/ld\+json"[^>]*>(.*?)</script>',
html, re.DOTALL): html, re.DOTALL):
try: try:
data = json.loads(m.group(1).strip()) data = json.loads(m.group(1).strip())
@ -42,13 +63,19 @@ def parse(html, package):
except Exception: pass except Exception: pass
break break
# installs (e.g. "1,000,000+") # installs (e.g. "1,000,000+"). Play wraps the count and the "Downloads"
m = re.search(r'([\d,]+\+)\s*<span>\s*Downloads', html) # label in their own elements; the label may be a <span> or a <div>.
m = re.search(r'([\d,]+\+)\s*</[^>]+>\s*<[^>]*>\s*Downloads', html)
if not m:
m = re.search(r'([\d,]+\+)\s*<span>\s*Downloads', html)
if m: if m:
out["installs"] = m.group(1) out["installs"] = m.group(1)
# updated date (e.g. "Updated on<span>Jun 1, 2026</span>") # updated date (e.g. "Updated on</div><div ...>Jun 1, 2026</div>" — the
m = re.search(r'Updated on\s*<span>([^<]+)</span>', html) # label and value live in adjacent elements that may be <span> or <div>).
m = re.search(r'Updated on\s*</[^>]+>\s*<[^>]*>([^<]+)</[^>]+>', html)
if not m:
m = re.search(r'Updated on\s*<span>([^<]+)</span>', html)
if m: if m:
out["updated"] = m.group(1).strip() out["updated"] = m.group(1).strip()

View File

@ -1,11 +1,11 @@
<!DOCTYPE html> <!DOCTYPE html>
<html><head><title>Example App - Apps on Google Play</title> <html><head><title>Example App - Apps on Google Play</title>
<script type="application/ld+json"> <script type="application/ld+json" nonce="5kflo12lNMmEYHKRtMGqHQ">
{"@type":"SoftwareApplication","name":"Example App","author":{"name":"Example Studio"}, {"@context":"https://schema.org","@type":"SoftwareApplication","name":"Example App","author":{"name":"Example Studio"},
"aggregateRating":{"ratingValue":"4.3","ratingCount":"12873"}, "aggregateRating":{"ratingValue":"4.3","ratingCount":"12873"},
"applicationCategory":"GAME_PUZZLE"} "applicationCategory":"GAME_PUZZLE"}
</script> </script>
</head><body> </head><body>
<div>Updated on<span>Jun 1, 2026</span></div> <div class="reAt0">Updated on</div><div class="xg1aie">Jun 1, 2026</div>
<div>1,000,000+<span>Downloads</span></div> <div class="ClM7O">1,000,000+</div><div class="g1rdde">Downloads</div>
</body></html> </body></html>

View File

@ -12,10 +12,20 @@ check "ends with scripts dir" "android-reverse-engineering/skills/android-revers
check "decompile.sh exists under resolved dir" "yes" \ check "decompile.sh exists under resolved dir" "yes" \
"$([[ -f "$out/decompile.sh" ]] && echo yes || echo no)" "$([[ -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/<marketplace>/android-reverse-engineering/<version>/...),
# 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)" tmp="$(mktemp -d)"
out2="$(CLAUDE_PLUGIN_ROOT="$tmp/clone-app" bash "$SCRIPT" 2>/dev/null)"; rc2=$? fake="$tmp/cache/some-marketplace/android-reverse-engineering/9.9.9/skills/android-reverse-engineering/scripts"
check "exit 1 when RE missing" "1" "$rc2" 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" rm -rf "$tmp"
exit $fail exit $fail

View File

@ -23,6 +23,7 @@ def main():
check("developer", d["developer"] == "Example Studio") check("developer", d["developer"] == "Example Studio")
check("category", d["category"] == "GAME_PUZZLE") check("category", d["category"] == "GAME_PUZZLE")
check("installs", d["installs"] == "1,000,000+") check("installs", d["installs"] == "1,000,000+")
check("updated", d["updated"] == "Jun 1, 2026")
check("source", d["source"] == "google-play") check("source", d["source"] == "google-play")
# all expected keys present even if null # all expected keys present even if null
for k in ["package","title","rating","rating_count","installs","category","developer","updated","source"]: for k in ["package","title","rating","rating_count","installs","category","developer","updated","source"]: