# -*- coding: utf-8 -*- import json, re, csv, gzip, statistics, os, collections, unicodedata, sys # Everything is read from, and written back into, the directory this script # lives in - which is the directory published at # https://novamya.com/data/panel-price-survey/. The page says re-running these # scripts reproduces its numbers offline; that is only true if the script can # find its inputs after somebody downloads it. HERE = os.path.dirname(os.path.abspath(__file__)) R = os.environ.get("PANEL_SURVEY_DIR") or HERE # A capture is named by its suffix: none for the 2 September capture, whose # files keep the names the article has linked since it was published, and # `-w2` for the 13 September one. # python3 analyse.py # services-all.json.gz + fetchlog.tsv -> services-basket.csv, stats.json # python3 analyse.py w2 # services-all-w2.json.gz + fetchlog-w2.tsv -> services-basket-w2.csv, stats-w2.json WAVE = sys.argv[1] if len(sys.argv) > 1 else "" if WAVE and not re.fullmatch(r"w[0-9]+", WAVE): sys.exit("usage: analyse.py [w2|w3|...]") SFX = "-" + WAVE if WAVE else "" rows = json.load(gzip.open(os.path.join(R, "services-all%s.json.gz" % SFX), "rt", encoding="utf-8")) # the parser turns
into " | "; strip the ones that ended up bracketing a field def tidy(v): if not isinstance(v, str): return v v = re.sub(r"^\s*\|\s*", "", v) v = re.sub(r"\s*\|\s*$", "", v) return re.sub(r"\s+", " ", v).strip() for r in rows: for k in ("name", "rate_raw", "min", "max", "avg", "category", "desc", "quality"): if k in r: r[k] = tidy(r[k]) FETCH = {} for line in open(os.path.join(R, "fetchlog%s.tsv" % SFX), encoding="utf-8"): p = line.rstrip("\n").split("\t") if len(p) >= 6: FETCH[p[0]] = dict(url=p[1], ts=p[2], code=p[3], final=p[4]) # `source_url` in the CSV is the URL that actually served the bytes, not the one # requested: peakerr.com/services redirected, and citing the requested URL made # the CSV point at a page that never held the data. DOMAIN = {"smmlaunch":"smmlaunch.com","adderpanel":"adderpanel.com","crescitaly":"crescitaly.com", "smmcost":"smmcost.com","smmkings":"smmkings.com","growfastsmm":"growfastsmm.com","1xpanel":"1xpanel.com", "morethanpanel":"morethanpanel.com","mysmm":"mysmm.co","smmfolgen":"smmfolgen.com","easytopromo":"easytopromo.com", "smmpanelone":"smmpanel.one","globalsmm":"global-smm.com","autosmo":"autosmo.com","peakerr":"peakerr.com"} RELATION = {"smmlaunch":"publisher-owned (disclosed)","adderpanel":"publisher-owned (disclosed)"} # Panels kept in the corpus but excluded from every price comparison, with the # reason carried into the CSV's `note` column so it travels with the data. EXCLUDE = { "growfastsmm": "prices listed in INR (₹), not USD - no observed FX rate, so excluded from USD comparisons", "peakerr": "requested /services redirected to the homepage (see fetchlog.tsv); the 8 rows are advertised headline prices with no service id, category, min or max, so they are not catalogue entries comparable with the others", } # ---------- normalisation for name matching ---------- def fold(s): s = unicodedata.normalize("NFKC", s) s = "".join(ch for ch in s if not unicodedata.category(ch).startswith("So")) # drop emoji/symbols s = s.lower() s = re.sub(r"[^a-z0-9]+", " ", s) return re.sub(r"\s+", " ", s).strip() # ---------- objective eligibility filter ------------------------------------- # A rate quoted "per 1,000" is only comparable if 1,000 units can actually be ordered, # and a service the panel itself files under a "don't use" heading is not on sale. DEAD_CAT = re.compile(r"do\s*n.?t\s*use|do\s+not\s+use|deprecated|disabled|closed|test\s*only", re.I) def order_max(r): d = re.sub(r"[^\d]", "", r.get("max") or "") return int(d) if d else None def eligible(r): if r["rate"] is None or r["rate"] <= 0: return False m = order_max(r) # A missing maximum is not a large maximum. Written as `m is not None and # m < 1000`, this test waved through every row on a panel that publishes no # order limits at all - which is precisely the row the filter exists to # catch - and one such row became the cheapest YouTube listing in the # published table. No published maximum, no place in the comparison. if m is None or m < 1000: return False if DEAD_CAT.search(r.get("category") or ""): return False if re.search(r"ask me|make ticket|contact us|do not order", r["name"], re.I): return False # A row whose name starts with "test" is a panel checking its own # checkout, not a product. Added for the 13 September capture, where a # new $0.15 row on smmlaunch.com named only `Test` would otherwise have # become that panel's cheapest Telegram members listing. Run against the # 2 September capture it changes one row and no published figure: # 1xpanel.com's Telegram members row, whose count of qualifying listings # goes from 823 to 822 and whose median across them from 2.3796 to 2.3648, # because `Test Server - Telegram Online Members` stops counting. if re.search(r"^\s*(?:id\s+)?test\b", r["name"], re.I): return False return True # ---------- basket definitions ---------- def hay(r): return fold(r["name"] + " " + r.get("category", "")) def is_tg_members(r): h = hay(r) if "telegram" not in h and "tg " not in h: return False if not re.search(r"\b(members?|subscribers?)\b", h): return False bad = ("view","reaction","vote","poll","report","comment","share","story","bot start","botstart", "session","tdata","api","gift","nft","stars","account","premium subscription","boost", "mute","unmute","leave","remove","refill only","auto ") return not any(b in h for b in bad) def is_tg_views(r): h = hay(r) if "telegram" not in h and "tg " not in h: return False if not re.search(r"\bviews?\b", h): return False bad = ("member","subscriber","account","session","tdata","report","reaction","auto","story", "premium","bot start","botstart","vote","poll","comment","share","api") return not any(b in h for b in bad) def is_ig_followers(r): h = hay(r) if "instagram" not in h and not re.search(r"\big\b", h): return False if not re.search(r"\bfollowers?\b", h): return False # "member": autosmo.com files `Instagram Channel Members` under an # "Instagram Followers" heading, and in the 13 September capture that row # became the panel's cheapest "followers" listing. On the 2 September # capture it changes one row and no published figure: autosmo.com's # Instagram followers count goes from 55 to 54 and its median from 3.93 # to 3.915; the cheapest listing, $1.17, is the same. bad = ("unfollow","remove","story","view","like","comment","reel","account","panel","report", "impression","save","share","live","tv","subs","refill only","drip feed only","auto ","member") return not any(b in h for b in bad) def is_yt_views(r): h = hay(r) if "youtube" not in h and not re.search(r"\byt\b", h): return False if not re.search(r"\bviews?\b", h): return False bad = ("live","premiere","watch time","watchtime","watch hours","hours","hour","ads","ad ", "shorts","subscriber","like","comment","share","report","music","stream","monetiz","auto ") return not any(b in h for b in bad) BASKET = [("Telegram channel members", is_tg_members), ("Telegram post views", is_tg_views), ("Instagram followers", is_ig_followers), ("YouTube views", is_yt_views)] # ---------- verbatim claim extraction ---------- START_PATS = [ r"start\s*time\s*[:\-]?\s*[^|\n\]]{1,40}", r"\[\s*start\s*[:\-]?\s*[^\]]{1,30}\]", r"\b\d+\s*-\s*\d+\s*(?:min(?:s|utes)?|h(?:rs?|ours?)?)\s*start\b", r"\binstant\s*start\b", r"\bstart\s*[:\-]\s*[^|\n\]]{1,30}", ] SPEED_PATS = [ r"speed\s*[:\-]\s*\d[^|\n\]]{0,30}", # "Speed: 100K/D" r"\bspeed\s+\d[^|\n\]]{0,30}", # "Speed 10k" r"\b\d[\d\s,\.]*\s*[kKmM]?\s*(?:\+|-\s*\d+\s*[kKmM]?)?\s*(?:per\s*day|/\s*day|/\s*d\b|/\s*D\b|daily)", ] # order matters: the qualifier in front of "refill" ("No", "30 Days", "Lifetime") is the # whole meaning of the claim, so those patterns must be tried before the bare keyword. REFILL_PATS = [ r"\b(?:no|non|zero|without)\s*[- ]?\s*refill\b", r"\b(?:lifetime|life\s*time|permanent|auto)\s*[- ]?\s*refill\b", r"\b\d+\s*[- ]?\s*(?:day|days|month|months|year|years|hour|hours|d|h)\s*(?:refill|guarantee|guaranteed|gaurantee|warranty)\b", r"\brefill\s*[:\-]\s*[^|\n\]]{1,30}", r"\brefill\s+\d+\s*(?:day|days|month|months|year|years)\b", r"\b(?:no|non|zero)\s*[- ]?\s*(?:guarantee|guaranteed|warranty)\b", r"\b\d+\s*(?:day|days|month|months|year|years)\s*(?:guarantee|guaranteed|gaurantee|warranty)\b", r"\bguarantee[d]?\s*[:\-]\s*[^|\n\]]{1,30}", r"\blifetime\s*(?:guarantee|guaranteed|warranty)\b", r"\brefill\b", r"\bguarantee[d]?\b", ] def grab(text, pats): t = text for p in pats: m = re.search(p, t, re.I) if m: v = re.sub(r"\s+", " ", m.group(0)).strip(" |[]-:") if v: return v return "" # "states a refill period" = names a duration (or 'lifetime') next to refill/guarantee, # OR explicitly says there is none ("No Refill"). Both are disclosures; counted separately. REFILL_PERIOD = re.compile(r"\b\d+\s*[- ]?\s*(?:day|days|hour|hours|month|months|year|years|d|h)\s*(?:refill|guarantee|guaranteed|gaurantee|warranty)|refill\s*[:\-]?\s*\d+\s*(?:day|days|month|months|year|years)|\b(?:lifetime|life\s*time|permanent)\s*[- ]?\s*(?:refill|guarantee|guaranteed|warranty)", re.I) REFILL_NONE = re.compile(r"\b(?:no|non|zero|without)\s*[- ]?\s*(?:refill|guarantee|guaranteed|warranty)\b", re.I) REFILL_ANY = re.compile(r"refill|guarantee|guaranteed|gaurantee|warranty", re.I) SPEED_RATE = re.compile(r"\b\d[\d\s,\.]*\s*[kKmM]?\s*(?:\+|-\s*\d+\s*[kKmM]?)?\s*(?:per\s*day|/\s*day|/\s*d\b|daily)|speed\s*[:\-]?\s*\d", re.I) START_ANY = re.compile(r"start\s*time|instant\s*start|\bstart\s*[:\-]|\[\s*start\b|\b\d+\s*-\s*\d+\s*(?:min|h(?:rs?|ours?)?)\s*start", re.I) # An "average time" column is only a measurement when it holds a measurement. # Counting every non-empty cell counted 5,251 `New Service` cells on 1xpanel, # 6,769 on crescitaly, six `Instant` promises on peakerr, and 123 smmfolgen # cells where the parser swallowed the whole row - 12,149 cells that state no # duration at all, and in the `New Service` case state the opposite: that the # panel has no statistic yet. A cell counts only if it contains a digit and is # not the parse failure, which is identifiable by the leaked column header. def has_duration(cell): c = (cell or "").strip(" |") return bool(re.search(r"\d", c)) and "Rate per 1000" not in c for r in rows: blob = r["name"] + " || " + (r.get("desc") or "") r["_blob"] = blob r["start_v"] = grab(blob, START_PATS) r["speed_v"] = grab(blob, SPEED_PATS) r["refill_v"] = grab(blob, REFILL_PATS) r["has_refill_period"] = bool(REFILL_PERIOD.search(blob)) r["has_refill_none"] = bool(REFILL_NONE.search(blob)) r["has_refill_word"] = bool(REFILL_ANY.search(blob)) r["eligible"] = eligible(r) r["has_speed_rate"] = bool(SPEED_RATE.search(blob)) r["has_start"] = bool(START_ANY.search(blob)) by_panel = collections.defaultdict(list) for r in rows: by_panel[r["panel"]].append(r) PANELS = list(DOMAIN.keys()) # Two different sets, and conflating them is how peakerr got into the price # table. USD = every panel whose prices are in dollars, which is the corpus the # claim percentages are measured over. COMPARABLE = the subset whose rows are # catalogue entries that can be priced against each other, which is the set the # spread table and the same-name price gaps are computed over. USD = [p for p in PANELS if p != "growfastsmm"] COMPARABLE = [p for p in PANELS if p not in EXCLUDE] # ---------- build CSV ---------- out = [] for p in PANELS: prs = by_panel[p] f = FETCH.get(p, {}) for label, fn in BASKET: matches = [r for r in prs if fn(r) and r["eligible"]] if not matches: out.append(dict(panel=DOMAIN[p], relationship=RELATION.get(p, "independent"), basket_item=label, matching_services_listed=0, service_id="", service_name_verbatim="not listed", price_per_1000=("not listed"), currency="", min_order="not listed", max_order="not listed", start_time_verbatim="not listed", delivery_speed_verbatim="not listed", refill_guarantee_verbatim="not listed", avg_completion_time_listed="not listed", panel_min_usd="", panel_median_usd="", panel_max_usd="", source_url=f.get("final") or f.get("url",""), read_utc=f.get("ts",""), note=EXCLUDE.get(p,""))) continue cheapest = min(matches, key=lambda r: r["rate"]) prices = sorted(r["rate"] for r in matches) out.append(dict(panel=DOMAIN[p], relationship=RELATION.get(p, "independent"), basket_item=label, matching_services_listed=len(matches), service_id=cheapest["sid"], service_name_verbatim=cheapest["name"], price_per_1000=cheapest["rate_raw"].strip(), currency=cheapest["currency"], min_order=(cheapest["min"] or "not listed").strip(), max_order=(cheapest["max"] or "not listed").strip(), start_time_verbatim=cheapest["start_v"] or "not listed", delivery_speed_verbatim=cheapest["speed_v"] or "not listed", refill_guarantee_verbatim=cheapest["refill_v"] or "not listed", avg_completion_time_listed=(cheapest["avg"].strip(" |") or "not listed"), panel_min_usd=f"{prices[0]:.4f}", panel_median_usd=f"{statistics.median(prices):.4f}", panel_max_usd=f"{prices[-1]:.4f}", source_url=f.get("final") or f.get("url",""), read_utc=f.get("ts",""), note=EXCLUDE.get(p,""))) cols = ["panel","relationship","basket_item","matching_services_listed","service_id", "service_name_verbatim","price_per_1000","currency","min_order","max_order", "start_time_verbatim","delivery_speed_verbatim","refill_guarantee_verbatim", "avg_completion_time_listed","panel_min_usd","panel_median_usd","panel_max_usd", "source_url","read_utc","note"] with open(os.path.join(R, "services-basket%s.csv" % SFX), "w", newline="", encoding="utf-8") as fh: w = csv.DictWriter(fh, fieldnames=cols); w.writeheader() for r in out: w.writerow(r) # ---------- stats ---------- print("=== BASKET SPREAD (comparable panels only, cheapest matching service per panel) ===") spread = {} for label, fn in BASKET: vals = [] for p in COMPARABLE: ms = [r for r in by_panel[p] if fn(r) and r["eligible"]] if ms: vals.append((DOMAIN[p], min(r["rate"] for r in ms))) vs = sorted(v for _, v in vals) if not vs: continue spread[label] = (len(vs), vs[0], statistics.median(vs), vs[-1], vs[-1]/vs[0]) lo = [d for d,v in vals if v==vs[0]]; hi=[d for d,v in vals if v==vs[-1]] print(f"{label:28s} n={len(vs):2d} min=${vs[0]:.4f} ({lo[0]}) median=${statistics.median(vs):.5f} max=${vs[-1]:.4f} ({hi[0]}) ratio={vs[-1]/vs[0]:.1f}x") print(" all:", ", ".join(f"{d}=${v:.4f}" for d,v in sorted(vals,key=lambda x:x[1]))) print() print("=== CATALOGUE SIZE & CLAIM DISCLOSURE (per panel, whole catalogue) ===") print(f"{'panel':22s} {'services':>8s} {'refill%':>8s} {'start%':>7s} {'speed/day%':>10s} {'avgdur%':>9s} {'desc-in-html':>12s}") disc = {} for p in PANELS: prs = by_panel[p]; n = len(prs) if not n: continue rf = sum(r["has_refill_period"] or r["has_refill_none"] for r in prs)/n*100 st = sum(r["has_start"] for r in prs)/n*100 sp = sum(r["has_speed_rate"] for r in prs)/n*100 av = sum(1 for r in prs if has_duration(r["avg"]))/n*100 de = sum(1 for r in prs if r.get("desc"))/n*100 disc[p]=(n,rf,st,sp,av,de) print(f"{DOMAIN[p]:22s} {n:8d} {rf:7.1f}% {st:6.1f}% {sp:9.1f}% {av:8.1f}% {de:11.1f}%") print() print("=== IDENTICAL SERVICE-NAME STRINGS ACROSS PANELS ===") norm = collections.defaultdict(set) for r in rows: k = fold(r["name"]) if len(k) < 12: continue norm[k].add(r["panel"]) shared = {k:v for k,v in norm.items() if len(v) > 1} print(f"distinct normalised service names: {len(norm)}; appearing on >1 panel: {len(shared)} ({len(shared)/len(norm)*100:.1f}%)") cnt = collections.Counter(len(v) for v in norm.values()) for k in sorted(cnt): print(f" name on {k} panel(s): {cnt[k]}") print() print("=== PAIRWISE CATALOGUE OVERLAP (Jaccard on normalised names, top pairs) ===") sets = {p: set(fold(r["name"]) for r in by_panel[p] if len(fold(r["name"]))>=12) for p in PANELS} pairs = [] ps = [p for p in PANELS if len(sets[p])>50] for i in range(len(ps)): for j in range(i+1, len(ps)): a, b = sets[ps[i]], sets[ps[j]] inter = len(a & b) if inter == 0: continue pairs.append((inter/len(a|b), inter, ps[i], ps[j], len(a), len(b))) pairs.sort(reverse=True) for jac, inter, a, b, la, lb in pairs[:12]: print(f" {DOMAIN[a]:18s} x {DOMAIN[b]:18s} jaccard={jac:.3f} shared={inter:5d} ({la} vs {lb} names)") json.dump({"spread":spread,"disclosure":disc}, open(os.path.join(R,"stats%s.json" % SFX),"w")) print("\nCSV rows:", len(out)) print() print("=== DISCLOSURE ON THE FILLED BASKET ROWS THEMSELVES ===") import csv as _csv br = [r for r in out if r["service_name_verbatim"] != "not listed"] def notlisted(r,k): return r[k] == "not listed" print(f"basket rows filled: {len(br)} of {len(out)}") print(f" state a start time : {sum(1 for r in br if not notlisted(r,'start_time_verbatim'))} / {len(br)}") print(f" state a delivery speed : {sum(1 for r in br if not notlisted(r,'delivery_speed_verbatim'))} / {len(br)}") print(f" state refill/guarantee : {sum(1 for r in br if not notlisted(r,'refill_guarantee_verbatim'))} / {len(br)}") print(f" list an average time : {sum(1 for r in br if not notlisted(r,'avg_completion_time_listed'))} / {len(br)}") print(f" list a max order : {sum(1 for r in br if not notlisted(r,'max_order'))} / {len(br)}") cmp_domains = {DOMAIN[p] for p in COMPARABLE} cb = [r for r in br if r["panel"] in cmp_domains] print(f" -- of those, the {len(cb)} rows behind the published spread table (comparable panels only):") print(f" state a start time : {sum(1 for r in cb if not notlisted(r,'start_time_verbatim'))} / {len(cb)}") print(f" state refill/guarantee : {sum(1 for r in cb if not notlisted(r,'refill_guarantee_verbatim'))} / {len(cb)}") print(f" list a max order : {sum(1 for r in cb if not notlisted(r,'max_order'))} / {len(cb)}") print() print("=== WHOLE-CORPUS CLAIM DISCLOSURE (USD-priced panels, %d services) ===" % sum(len(by_panel[p]) for p in USD)) allu = [r for p in USD for r in by_panel[p]] n = len(allu) print(f" name a refill/guarantee DURATION : {sum(r['has_refill_period'] for r in allu):6d} ({sum(r['has_refill_period'] for r in allu)/n*100:.1f}%)") print(f" explicitly say NO refill/guarantee : {sum(r['has_refill_none'] for r in allu):6d} ({sum(r['has_refill_none'] for r in allu)/n*100:.1f}%)") print(f" say nothing at all about refill : {sum(not r['has_refill_word'] for r in allu):6d} ({sum(not r['has_refill_word'] for r in allu)/n*100:.1f}%)") _rest = sum(1 for r in allu if r['has_refill_word'] and not r['has_refill_period'] and not r['has_refill_none']) _both = sum(1 for r in allu if r['has_refill_period'] and r['has_refill_none']) print(f" a refill word but neither of the above : {_rest:6d} ({_rest/n*100:.1f}%) <- why the first three do not sum to 100%") print(f" counted in both of the first two lines : {_both:6d}") print(f" state a start time : {sum(r['has_start'] for r in allu):6d} ({sum(r['has_start'] for r in allu)/n*100:.1f}%)") print(f" quote a delivery rate per day : {sum(r['has_speed_rate'] for r in allu):6d} ({sum(r['has_speed_rate'] for r in allu)/n*100:.1f}%)") print(f" print a duration in the avg-time col : {sum(1 for r in allu if has_duration(r['avg'])):6d} ({sum(1 for r in allu if has_duration(r['avg']))/n*100:.1f}%)") print(f" (non-empty avg-time cell, any content): {sum(1 for r in allu if r['avg'].strip(' |')):6d} ({sum(1 for r in allu if r['avg'].strip(' |'))/n*100:.1f}%)") print() print("=== NAMES SHARED BY 3+ PANELS (sample) ===") tri = sorted([(len(v), k, sorted(DOMAIN[x] for x in v)) for k, v in norm.items() if len(v) >= 3], reverse=True) for c, k, v in tri[:14]: print(f" [{c}] {k[:78]:78s} {v}") print() print("=== HOW MANY PANELS SHARE >=1 IDENTICAL NAME WITH ANOTHER PANEL ===") share_panels = set() for k, v in norm.items(): if len(v) > 1: share_panels |= v print(f" {len(share_panels)} of {len(PANELS)} panels: {sorted(DOMAIN[p] for p in share_panels)}") print() print("=== PRICE OF THE SAME NAME STRING ON DIFFERENT PANELS (largest gaps) ===") byname = collections.defaultdict(list) for r in rows: if r["panel"] in EXCLUDE or not r["eligible"]: continue byname[fold(r["name"])].append((r["panel"], r["rate"], r["name"])) gaps = [] for k, v in byname.items(): ps = {} for pn, rate, nm in v: ps.setdefault(pn, []).append(rate) if len(ps) < 2: continue mins = {pn: min(x) for pn, x in ps.items()} lo = min(mins.values()); hi = max(mins.values()) if lo > 0: gaps.append((hi/lo, lo, hi, k, {DOMAIN[a]: b for a, b in mins.items()}, v[0][2])) gaps.sort(reverse=True) print(f" name strings priced by 2+ panels: {len(gaps)}") import statistics as st print(f" median max/min ratio for the same name string: {st.median([g[0] for g in gaps]):.2f}x") print(f" same name, same price on every panel listing it: {sum(1 for g in gaps if g[0]==1.0)} ({sum(1 for g in gaps if g[0]==1.0)/len(gaps)*100:.1f}%)") for g in gaps[:8]: print(f" {g[0]:7.1f}x ${g[1]:.4f}->${g[2]:.4f} {g[5][:60]} {g[4]}")