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
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 = []

View File

@ -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/<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
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

View File

@ -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'<script type="application/ld\+json">(.*?)</script>',
# ld+json SoftwareApplication block. The <script> tag may carry extra
# 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):
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*<span>\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 <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:
out["installs"] = m.group(1)
# updated date (e.g. "Updated on<span>Jun 1, 2026</span>")
m = re.search(r'Updated on\s*<span>([^<]+)</span>', html)
# updated date (e.g. "Updated on</div><div ...>Jun 1, 2026</div>" — the
# 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:
out["updated"] = m.group(1).strip()

View File

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

View File

@ -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"]: