allyotter-lite 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ dist/
6
+ output/
7
+ private/
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AMF Indiana
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.5
2
+ Name: allyotter-lite
3
+ Version: 0.1.0
4
+ Summary: Get a digital product ready for affiliate networks (WarriorPlus, JVZoo, Digistore24, Whop, ClickBank and more) from Claude Code, Codex or any MCP client: requirements, a sales-page readiness audit, submission checklists and a tracker. Free tools from AllyOtter.
5
+ Project-URL: Homepage, https://allyotter.com
6
+ Author: AMF Indiana (AllyOtter)
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: affiliate,claude,clickbank,codex,digistore24,jvzoo,mcp,warriorplus
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: mcp<2,>=1.28.1
12
+ Description-Content-Type: text/markdown
13
+
14
+ # AllyOtter Lite
15
+
16
+ Free tools that get a digital product ready for affiliate networks (WarriorPlus, JVZoo, Digistore24, Whop, ClickBank,
17
+ Gumroad and more: 15 networks) from Claude Code, Codex, Claude Desktop or any MCP client.
18
+
19
+ - `pick_networks`: which networks fit your product (kind, price, one-time or recurring), ranked with reasons.
20
+ - `network_requirements`: everything one network needs: seller setup (and which steps only you, the account owner,
21
+ can do), product fields, the sales-page rules reviewers enforce, affiliate page, delivery (IPN/webhook), reviewer
22
+ access, approval, affiliate approval, marketplace visibility switches, refunds, known rejection reasons, dated sources.
23
+ - `readiness_audit`: reads your public sales page and flags what reviewers reject: no price, refund or contact, missing
24
+ results disclaimer, income promises, fake scarcity, and each network's own rules (JVZoo's off-page links, the
25
+ WarriorPlus buy button and disclaimer, Digistore24 and ClickBank checkout links).
26
+ - `submission_checklist`: a field-by-field checklist for one network, prefilled from your product facts.
27
+ - `track_submission` / `list_submissions`: where each submission stands, with the next action (on these networks
28
+ "approved" is not "live"). Saved on your computer only, in `~/.allyotter/submissions.json`.
29
+ - `allyotter_full_kit`: what the paid kit adds.
30
+
31
+ No sign-in, no browser, no API keys. It never submits or sends anything.
32
+
33
+ ## Install
34
+
35
+ Claude Code:
36
+
37
+ claude mcp add allyotter-lite -- uvx allyotter-lite
38
+
39
+ Codex (`~/.codex/config.toml`):
40
+
41
+ [mcp_servers.allyotter-lite]
42
+ command = "uvx"
43
+ args = ["allyotter-lite"]
44
+
45
+ Claude Desktop and other MCP clients: run `uvx allyotter-lite` as a stdio server.
46
+
47
+ ## Why this exists
48
+
49
+ Our AI (Claude Code and Codex) prepared our own product, ThreadFox, for WarriorPlus, JVZoo, Digistore24 and Whop, fixed
50
+ what each reviewer asked for, and submitted it. WarriorPlus approved it on 2026-09-26 and that evening WarriorPlus's own
51
+ marketplace traffic sold the first copy: $49, with no ad spend. One sale; our result, not a promise of yours. Every
52
+ first-hand entry in this knowledge base comes from that run; the rest come from each network's own published pages,
53
+ with the date we read them. Networks change their rules, so re-check the cited source before you act.
54
+
55
+ ## The paid kit
56
+
57
+ The AllyOtter kit ($49 once) has your AI write each network's own sales-page version and your JV page, prepare every
58
+ submission as a copy-paste pack (each field in the form's order, sized to its limit), set up Whop through its official
59
+ API, hook up delivery with the verification code we run in production, track approvals and invite affiliates,
60
+ personally and commission-only. Networks forbid bots on their dashboards, so you paste and click. https://allyotter.com
61
+
62
+ Made by AMF Indiana, the team behind ThreadFox. Not affiliated with any network named here. MIT licence.
@@ -0,0 +1,49 @@
1
+ # AllyOtter Lite
2
+
3
+ Free tools that get a digital product ready for affiliate networks (WarriorPlus, JVZoo, Digistore24, Whop, ClickBank,
4
+ Gumroad and more: 15 networks) from Claude Code, Codex, Claude Desktop or any MCP client.
5
+
6
+ - `pick_networks`: which networks fit your product (kind, price, one-time or recurring), ranked with reasons.
7
+ - `network_requirements`: everything one network needs: seller setup (and which steps only you, the account owner,
8
+ can do), product fields, the sales-page rules reviewers enforce, affiliate page, delivery (IPN/webhook), reviewer
9
+ access, approval, affiliate approval, marketplace visibility switches, refunds, known rejection reasons, dated sources.
10
+ - `readiness_audit`: reads your public sales page and flags what reviewers reject: no price, refund or contact, missing
11
+ results disclaimer, income promises, fake scarcity, and each network's own rules (JVZoo's off-page links, the
12
+ WarriorPlus buy button and disclaimer, Digistore24 and ClickBank checkout links).
13
+ - `submission_checklist`: a field-by-field checklist for one network, prefilled from your product facts.
14
+ - `track_submission` / `list_submissions`: where each submission stands, with the next action (on these networks
15
+ "approved" is not "live"). Saved on your computer only, in `~/.allyotter/submissions.json`.
16
+ - `allyotter_full_kit`: what the paid kit adds.
17
+
18
+ No sign-in, no browser, no API keys. It never submits or sends anything.
19
+
20
+ ## Install
21
+
22
+ Claude Code:
23
+
24
+ claude mcp add allyotter-lite -- uvx allyotter-lite
25
+
26
+ Codex (`~/.codex/config.toml`):
27
+
28
+ [mcp_servers.allyotter-lite]
29
+ command = "uvx"
30
+ args = ["allyotter-lite"]
31
+
32
+ Claude Desktop and other MCP clients: run `uvx allyotter-lite` as a stdio server.
33
+
34
+ ## Why this exists
35
+
36
+ Our AI (Claude Code and Codex) prepared our own product, ThreadFox, for WarriorPlus, JVZoo, Digistore24 and Whop, fixed
37
+ what each reviewer asked for, and submitted it. WarriorPlus approved it on 2026-09-26 and that evening WarriorPlus's own
38
+ marketplace traffic sold the first copy: $49, with no ad spend. One sale; our result, not a promise of yours. Every
39
+ first-hand entry in this knowledge base comes from that run; the rest come from each network's own published pages,
40
+ with the date we read them. Networks change their rules, so re-check the cited source before you act.
41
+
42
+ ## The paid kit
43
+
44
+ The AllyOtter kit ($49 once) has your AI write each network's own sales-page version and your JV page, prepare every
45
+ submission as a copy-paste pack (each field in the form's order, sized to its limit), set up Whop through its official
46
+ API, hook up delivery with the verification code we run in production, track approvals and invite affiliates,
47
+ personally and commission-only. Networks forbid bots on their dashboards, so you paste and click. https://allyotter.com
48
+
49
+ Made by AMF Indiana, the team behind ThreadFox. Not affiliated with any network named here. MIT licence.
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "allyotter-lite"
3
+ version = "0.1.0"
4
+ description = "Get a digital product ready for affiliate networks (WarriorPlus, JVZoo, Digistore24, Whop, ClickBank and more) from Claude Code, Codex or any MCP client: requirements, a sales-page readiness audit, submission checklists and a tracker. Free tools from AllyOtter."
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ requires-python = ">=3.10"
8
+ authors = [{ name = "AMF Indiana (AllyOtter)" }]
9
+ keywords = ["affiliate", "warriorplus", "jvzoo", "digistore24", "clickbank", "mcp", "claude", "codex"]
10
+ dependencies = ["mcp>=1.28.1,<2"]
11
+
12
+ [project.urls]
13
+ Homepage = "https://allyotter.com"
14
+
15
+ [project.scripts]
16
+ allyotter-lite = "allyotter_lite.server:main"
17
+
18
+ [build-system]
19
+ requires = ["hatchling"]
20
+ build-backend = "hatchling.build"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/allyotter_lite"]
@@ -0,0 +1,43 @@
1
+ ---
2
+ name: affiliate-launch-first
3
+ description: List a digital product on affiliate networks (WarriorPlus, JVZoo, Digistore24, Whop, ClickBank and others) the way reviewers approve it. Pick networks that fit, make a sales-page version per network, hook up delivery, give the reviewer access, and remember that approved is not live. Use when an agent is asked to launch, list or submit a product to an affiliate network or marketplace.
4
+ ---
5
+
6
+ # Affiliate networks, reviewer first
7
+
8
+ Most rejections are fixable page problems. Follow these steps for each network.
9
+
10
+ ## Choose
11
+
12
+ 1. Pick two or three networks that fit the product's kind and price (AllyOtter Lite: `pick_networks`). Each one needs
13
+ its own page version, delivery hook-up and reviewer access.
14
+ 2. Read the network's current requirements the day you submit (AllyOtter Lite: `network_requirements`, then open the
15
+ cited sources).
16
+
17
+ ## Prepare the page
18
+
19
+ 3. Make a version of the sales page for that network: its buy button is the only checkout, its disclaimer sits where
20
+ it asks (WarriorPlus: the `wplus_spdisclaimer` div under the button), no links leave the page on JVZoo.
21
+ 4. Price, refund window, support contact, terms and privacy links, and the business name must be visible.
22
+ 5. If the page mentions any result, it's yours, qualified, with a disclaimer beside it. Never promise earnings. No
23
+ invented scarcity or evergreen timers.
24
+ 6. Audit it (AllyOtter Lite: `readiness_audit` with the network ids) and clear every blocker.
25
+
26
+ ## Submit
27
+
28
+ 7. Hook up delivery before submitting: the network's IPN/webhook, a thank-you or access page, and a test.
29
+ 8. Give the reviewer working access (a login or a $0 library link) and say where it is.
30
+ 9. Identity, tax, payment connections, legal attestations and CAPTCHAs are the account owner's own clicks. Stop and ask.
31
+
32
+ ## After
33
+
34
+ 10. The first "approval" mail may be a pending notice listing fixes. Fix exactly those and reply in the network's channel.
35
+ 11. Approved is not live: activate or publish the offer, then open the public buy link signed out.
36
+ 12. Switch on the affiliate settings (program on, requests allowed, marketplace visibility). Early affiliate requests
37
+ often need a manual approve: check daily.
38
+ 13. Record each state (AllyOtter Lite: `track_submission`).
39
+
40
+ ## Want the preparation done for you?
41
+
42
+ The AllyOtter kit has your AI write each network's page version, your JV page and a copy-paste submission pack for
43
+ every form, set up Whop through its official API, hook up delivery and track affiliates. https://allyotter.com
@@ -0,0 +1,2 @@
1
+ """AllyOtter Lite: free tools that get a digital product ready for affiliate networks."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,308 @@
1
+ """Readiness audit: read a public sales page and flag what affiliate-network reviewers reject.
2
+
3
+ Every check here is one we met in a real review (ThreadFox on WarriorPlus, JVZoo, Digistore24 and Whop, September 2026)
4
+ or one a network publishes. The audit reads the page's HTML only; it cannot see a page behind a login, and it says so.
5
+ It never submits anything.
6
+ """
7
+ import html
8
+ import re
9
+ import urllib.error
10
+ import urllib.request
11
+ from html.parser import HTMLParser
12
+ from urllib.parse import urljoin, urlsplit
13
+
14
+ MAX_BYTES = 3 * 1024 * 1024
15
+ TIMEOUT = 20
16
+ USER_AGENT = "Mozilla/5.0 (compatible; AllyOtterLite/0.1; +https://allyotter.com/lite)"
17
+
18
+ # Hosts that are the network's own checkout, scripts or images. A link to one of these is never "off-page".
19
+ NETWORK_HOSTS = {
20
+ "warriorplus": ("warriorplus.com",),
21
+ "explodely": ("explodely.com",),
22
+ "jvzoo": ("jvzoo.com", "jvz1.com", "jvz2.com", "jvz3.com", "jvz4.com", "jvz5.com", "jvz6.com", "jvz7.com", "jvz8.com", "jvz9.com"),
23
+ "digistore24": ("digistore24.com", "checkout-ds24.com", "digistore24-app.com"),
24
+ "clickbank": ("clickbank.net", "clickbank.com", "hop.clickbank.net"),
25
+ "whop": ("whop.com",),
26
+ "gumroad": ("gumroad.com",),
27
+ "payhip": ("payhip.com",),
28
+ "lemonsqueezy": ("lemonsqueezy.com",),
29
+ }
30
+
31
+
32
+ class _Page(HTMLParser):
33
+ def __init__(self):
34
+ super().__init__(convert_charrefs=True)
35
+ self.links = [] # (href, text)
36
+ self.scripts = [] # src
37
+ self.images = [] # src
38
+ self.ids = set()
39
+ self.classes = set()
40
+ self.meta = {}
41
+ self.text = []
42
+ self.title = ""
43
+ self._in = [] # open script/style/title tags
44
+ self._a = None
45
+
46
+ def handle_starttag(self, tag, attrs):
47
+ a = dict(attrs)
48
+ if a.get("id"):
49
+ self.ids.add((a.get("id") or "").strip())
50
+ for c in (a.get("class") or "").split():
51
+ self.classes.add(c)
52
+ if tag == "a":
53
+ self._a = [a.get("href") or "", []]
54
+ elif tag == "script":
55
+ if a.get("src"):
56
+ self.scripts.append(a["src"])
57
+ self._in.append("script")
58
+ elif tag in ("style", "title"):
59
+ self._in.append(tag)
60
+ elif tag == "meta":
61
+ key = (a.get("name") or a.get("property") or "").lower()
62
+ if key:
63
+ self.meta[key] = a.get("content") or ""
64
+ elif tag == "form" and a.get("action"):
65
+ self.links.append((a["action"], "form"))
66
+ elif tag == "img":
67
+ if a.get("src"):
68
+ self.images.append(a["src"])
69
+ if self._a is not None and a.get("alt"):
70
+ self._a[1].append(a["alt"])
71
+
72
+ def handle_endtag(self, tag):
73
+ if tag == "a" and self._a is not None:
74
+ self.links.append((self._a[0], " ".join(self._a[1]).strip()))
75
+ self._a = None
76
+ elif self._in and self._in[-1] == tag:
77
+ self._in.pop()
78
+
79
+ def handle_data(self, data):
80
+ if self._in and self._in[-1] in ("script", "style"):
81
+ return
82
+ if self._in and self._in[-1] == "title":
83
+ self.title += data
84
+ return
85
+ if self._a is not None:
86
+ self._a[1].append(data)
87
+ self.text.append(data)
88
+
89
+
90
+ def fetch(url):
91
+ """GET a public page. Returns (final_url, status, html) or raises ValueError with a plain reason."""
92
+ if not re.match(r"^https?://", url or "", re.I):
93
+ raise ValueError("Give the full public URL of the sales page, starting with https://")
94
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "text/html,*/*;q=0.5"})
95
+ try:
96
+ with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
97
+ body = r.read(MAX_BYTES + 1)
98
+ charset = r.headers.get_content_charset() or "utf-8"
99
+ return r.geturl(), r.status, body[:MAX_BYTES].decode(charset, "replace")
100
+ except urllib.error.HTTPError as e:
101
+ raise ValueError(f"The page answered HTTP {e.code}. Reviewers must be able to open it without signing in.")
102
+ except (urllib.error.URLError, TimeoutError, OSError) as e:
103
+ raise ValueError(f"Could not open the page ({getattr(e, 'reason', e)}).")
104
+
105
+
106
+ def _host(u):
107
+ h = (urlsplit(u).hostname or "").lower()
108
+ return h[4:] if h.startswith("www.") else h
109
+
110
+
111
+ def _on(host, domains):
112
+ return any(host == d or host.endswith("." + d) for d in domains)
113
+
114
+
115
+ # A result stated as earnings or as a number of sales, views, leads...; product words like "sales page" are not claims.
116
+ RESULT_WORDS = re.compile(r"\b(earn(ed|ings)|income|profits?|made \$\d|\$\d[\d,]*(\.\d\d)? (in|per|a|every) (day|week|month)|"
117
+ r"\d[\d,.]*k?\+? (sales|views|visitors|clicks|leads|subscribers|customers|signups|sign-ups|downloads|buyers|orders))\b", re.I)
118
+ DISCLAIMER = re.compile(r"(disclaimer|not typical|no guarantee|not a (promise|guarantee)|results (vary|will vary|are not)|"
119
+ r"individual results|your results|results depend|(do not|don't|don’t|cannot|can't|can’t) (promise|guarantee))", re.I)
120
+ INCOME_CLAIM = re.compile(r"(guaranteed (income|profits?|results|sales|commissions)|get rich|passive income|make money (in your sleep|while you sleep)|"
121
+ r"(make|earn|making|earning) (\$|up to \$)\s?\d[\d,]*\+? ?(k\b)?\s*(per|a|every|/|in (a|one|24)) ?(day|week|month|hour|hours)|"
122
+ r"\$\d[\d,]*\+? (per|a|every) (day|week) (on )?autopilot|push[- ]button (profits|income|cash)|profits on autopilot|"
123
+ r"business in a box|done[- ]for[- ]you (profits|income|cash)|overnight (riches|profits|income))", re.I)
124
+ NEGATION = re.compile(r"\b(no|not|never|without|isn't|is not|doesn't|does not|don't|do not|zero)\b[^.!?]{0,20}$", re.I)
125
+ SCARCITY = re.compile(r"(only \d+ (copies|spots|licenses|seats|left)|price (goes|will go) up in|"
126
+ r"(offer|sale|discount) (ends|expires) in \d|closing (tonight|forever)|last chance)", re.I)
127
+ TIMER_HINT = re.compile(r"(countdown|evergreen-?timer|deadlinefunnel|motion\.?page|timer\.js)", re.I)
128
+ PRICE = re.compile(r"(\$|USD\s?|US\$|€|£)\s?\d[\d,]*(\.\d\d)?")
129
+ REFUND = re.compile(r"(refund|money[- ]back|return policy|\d{1,3}[- ]day[^.]{0,40}guarantee|guarantee[^.]{0,40}\d{1,3}[- ]days?)", re.I)
130
+ REFUND_DAYS = re.compile(r"(\d{1,3})[- ]day[^.]{0,80}?(refund|money[- ]back|guarantee|return)|(refund|money[- ]back|guarantee|return)[^.]{0,80}?(\d{1,3})[- ]days?", re.I)
131
+ CONTACT = re.compile(r"(support@|help@|hello@|contact@|contact us|support desk|helpdesk|support ticket)", re.I)
132
+ IDENTITY = re.compile(r"(©|\(c\)|copyright)\s*(19|20)\d\d", re.I)
133
+ DELIVERY = re.compile(r"(instant (access|download)|download|delivered|access (link|details)|login details|members area|"
134
+ r"you('ll| will) (get|receive)|what you get|what's included|included)", re.I)
135
+ LIFETIME = re.compile(r"\b(lifetime|life-long|lifelong|forever|perpetual|unlimited access|permanent access|unrestricted access)\b", re.I)
136
+ LIFETIME_DEFINED = re.compile(r"(lifetime of (the|this) product|lifetime (refers|means)|lifetime is defined)", re.I)
137
+ PLR = re.compile(r"\b(PLR|private label rights|master resell rights|MRR|resell rights|resale rights|white[- ]label|rebrand (it )?as your own)\b", re.I)
138
+ CHECKOUTISH = re.compile(r"(checkout|/buy\b|/pay\b|/order\b|/cart\b|buy\.stripe\.com|checkout\.stripe\.com|paypal\.com/(cgi-bin|checkoutnow|paypalme)|"
139
+ r"gumroad\.com/l/|lemonsqueezy\.com/checkout|whop\.com/checkout|paddle\.com|samcart|thrivecart|payhip\.com/b/)", re.I)
140
+ BUY_TEXT = re.compile(r"\b(buy|order|get (it|instant|access|started)|add to cart|checkout|purchase|join now)\b", re.I)
141
+ CHECKOUT_URL = {
142
+ "warriorplus": re.compile(r"^https://(www\.)?warriorplus\.com/o2/buy/", re.I),
143
+ "jvzoo": re.compile(r"^https://(www\.)?jvzoo\.com/b/\d+/\d+/\d+", re.I),
144
+ "digistore24": re.compile(r"^https://(www\.)?(checkout-ds24\.com|digistore24\.com)/(product|redir)/", re.I),
145
+ "clickbank": re.compile(r"^https://[a-z0-9]+\.pay\.clickbank\.net/", re.I),
146
+ "explodely": re.compile(r"^https://(www\.)?explodely\.com/p/", re.I),
147
+ }
148
+ NETWORK_NAMES = {"warriorplus": "WarriorPlus", "jvzoo": "JVZoo", "digistore24": "Digistore24", "clickbank": "ClickBank", "explodely": "Explodely"}
149
+
150
+
151
+ def _claims(pattern, text):
152
+ """Matches of a claim pattern that are not negated just before ("no guaranteed income")."""
153
+ out = []
154
+ for m in pattern.finditer(text):
155
+ if not NEGATION.search(text[max(0, m.start() - 30):m.start()]):
156
+ out.append(m.group(0))
157
+ return out
158
+
159
+
160
+ def audit(url, networks=None, page_html=None):
161
+ """Audit a public sales page for the given networks (ids such as 'warriorplus', 'jvzoo'). Returns findings."""
162
+ networks = [n.strip().lower() for n in (networks or []) if n and n.strip()]
163
+ if page_html is None:
164
+ final, _, page_html = fetch(url)
165
+ else:
166
+ final = url
167
+ p = _Page()
168
+ p.feed(page_html)
169
+ text = html.unescape(" ".join(" ".join(p.text).split()))
170
+ low = text.lower()
171
+ own = _host(final)
172
+ links = [(urljoin(final, h.strip()), t) for h, t in p.links if h and not h.strip().startswith(("#", "javascript:"))]
173
+ ext = [(u, t) for u, t in links if urlsplit(u).scheme in ("http", "https") and _host(u) and _host(u) != own]
174
+ mail = [u for u, _ in links if u.startswith("mailto:")]
175
+ hrefs = " ".join(u.lower() for u, _ in links)
176
+ link_words = " ".join((u + " " + t).lower() for u, t in links)
177
+ found = []
178
+
179
+ def add(check, severity, what, fix, applies=None, evidence=None):
180
+ item = {"check": check, "severity": severity, "what": what, "fix": fix}
181
+ if applies:
182
+ item["networks"] = applies
183
+ if evidence:
184
+ item["evidence"] = evidence[:5] if isinstance(evidence, list) else evidence
185
+ found.append(item)
186
+
187
+ if not final.lower().startswith("https://"):
188
+ add("https", "blocker", "The page is not served over HTTPS.", "Serve the sales page over HTTPS; networks and browsers flag plain HTTP.")
189
+ if not PRICE.search(text):
190
+ add("price_visible", "blocker", "No price is visible on the page.", "Show the exact price the buyer pays next to the buy button, matching the network listing.")
191
+ if not REFUND.search(text):
192
+ add("refund_policy", "blocker", "No refund or money-back wording found.",
193
+ "State the refund window and how to ask (e.g. '30-day refund, email support@...'), matching the network's own refund rules.")
194
+ else:
195
+ days = sorted({int(m.group(1) or m.group(4)) for m in REFUND_DAYS.finditer(text)})
196
+ if len(days) > 1:
197
+ add("refund_consistent", "warning", f"More than one refund period appears on the page ({', '.join(map(str, days))} days).",
198
+ "State one refund period everywhere (page, terms, network setting) and who processes refunds.", evidence=[f"{d} days" for d in days])
199
+ if not (mail or CONTACT.search(text) or re.search(r"\b(support|contact|help)\b", link_words)):
200
+ add("contact", "blocker", "No support contact found.", "Add a support email address or support link that a buyer and a reviewer can see.")
201
+ if "terms" not in hrefs and not re.search(r"\bterms\b", low):
202
+ add("terms_link", "warning", "No terms link found.", "Link your terms of sale/license (most networks ask for a terms URL).")
203
+ if "privacy" not in link_words and "privacy" not in low:
204
+ add("privacy_link", "warning", "No privacy policy link found.", "Link a privacy policy, especially if the page collects emails or uses pixels.")
205
+ if not IDENTITY.search(text):
206
+ add("business_identity", "warning", "No copyright or business line found.", "Name the business that sells the product in the footer (e.g. '© 2026 Your Business').")
207
+ if not DELIVERY.search(text):
208
+ add("delivery_described", "warning", "The page does not say how the buyer gets the product.",
209
+ "Say what the buyer receives and how (instant download, members area, email with access link).")
210
+ results = _claims(RESULT_WORDS, text)
211
+ if results and not DISCLAIMER.search(text):
212
+ add("results_disclaimer", "blocker", "The page states results (earnings, or numbers of sales, views, leads...) but has no results disclaimer.",
213
+ "Add a plain disclaimer near the claims: your results are yours, not typical or promised, and depend on the buyer's work.",
214
+ evidence=sorted(set(results)))
215
+ promises = _claims(INCOME_CLAIM, text)
216
+ if promises:
217
+ add("income_claims", "blocker", "Income or profit promises found; reviewers and the FTC treat these as unsubstantiated claims.",
218
+ "Remove promises of earnings. Describe what the product does and, if you share a result, show it as yours with a disclaimer.",
219
+ evidence=sorted(set(promises)))
220
+ if SCARCITY.search(text) or any(TIMER_HINT.search(s) for s in p.scripts) or TIMER_HINT.search(" ".join(p.classes | p.ids)):
221
+ add("fake_scarcity", "warning", "Countdown or scarcity wording found.",
222
+ "Keep only real deadlines and real limits. Evergreen timers and invented scarcity get pages rejected.")
223
+ if "viewport" not in p.meta:
224
+ add("mobile_viewport", "warning", "No mobile viewport tag.", "Add <meta name=viewport content='width=device-width, initial-scale=1'>; most marketplace buyers are on phones.")
225
+
226
+ lifetime = LIFETIME.findall(text)
227
+ if lifetime:
228
+ strict = [n for n in networks if n in ("whop", "digistore24")]
229
+ loose = [n for n in networks if n in ("warriorplus", "jvzoo")]
230
+ if strict:
231
+ add("lifetime_claims", "blocker", "'Lifetime'/'forever' access promised: Whop prohibits it and Digistore24 rejects it (access is at most two years there).",
232
+ "Replace with a concrete term, e.g. '24 months of access and updates'.", strict, sorted(set(w.lower() for w in lifetime)))
233
+ elif loose and not LIFETIME_DEFINED.search(text):
234
+ add("lifetime_defined", "warning", "'Lifetime' is used without saying what it means; WarriorPlus and JVZoo allow it only when defined.",
235
+ "Add one sentence, e.g. 'Lifetime means the lifetime of the product.'", loose)
236
+ resale = [n for n in networks if n in ("gumroad", "lemonsqueezy", "clickbank", "appsumo")]
237
+ if resale and PLR.search(text):
238
+ add("resale_rights", "blocker", "Resale/PLR rights mentioned: Gumroad and Lemon Squeezy ban PLR/MRR, ClickBank bans products valued only for resale, AppSumo excludes them.",
239
+ "Sell only original work there, or move the resale bundle to a network that allows it.", resale, sorted(set(PLR.findall(text))))
240
+
241
+ for n in networks:
242
+ rule = CHECKOUT_URL.get(n)
243
+ if not rule:
244
+ continue
245
+ name = NETWORK_NAMES[n]
246
+ mine = [u for u, _ in links if rule.match(u)]
247
+ if not mine:
248
+ add(f"{n}_buy_button", "blocker", f"No {name} checkout link found.",
249
+ f"Use the {name} buy link/button code from the product's own settings as the checkout.", [n])
250
+ others = sorted({u for u, t in links if not rule.match(u) and not _on(_host(u), NETWORK_HOSTS.get(n, ()))
251
+ and (CHECKOUTISH.search(u) or (BUY_TEXT.search(t or "") and urlsplit(u).scheme in ("http", "https")))})
252
+ if others:
253
+ add(f"{n}_single_checkout", "blocker", f"Other checkouts or buy links on the page: {name} must be the only way to pay.",
254
+ f"On the {name} version of the page point every buy button at the {name} checkout.", [n], others)
255
+
256
+ offpage = [u for u, t in ext if not _on(_host(u), tuple(d for n in networks for d in NETWORK_HOSTS.get(n, ())))
257
+ and not re.search(r"(privacy|terms|refund|support|contact|help|legal|clkbank)", (u + " " + (t or "")).lower())]
258
+ if "jvzoo" in networks:
259
+ if not any(re.match(r"^https://i\.jvzoo\.com/", urljoin(final, s)) for s in p.images):
260
+ add("jvzoo_pixel", "blocker", "JVZoo's tracking pixel (i.jvzoo.com image in its buy-button code) was not found; JVZoo cannot verify the button without it.",
261
+ "Paste the unedited button code from the product's Buy Buttons menu.", ["jvzoo"])
262
+ if not ("does not provide any guarantee of income or success" in low and "jvzoo serves as the retailer" in low):
263
+ add("jvzoo_disclaimer", "blocker", "JVZoo's required disclaimer is missing (the no-income-guarantee paragraph and 'JVZoo serves as the retailer...').",
264
+ "Paste JVZoo's disclaimer verbatim at body font size (bottom of the page is fine).", ["jvzoo"])
265
+ if offpage:
266
+ add("jvzoo_offpage_links", "blocker", "JVZoo's compliance review asked us to remove links that leave the sales page.",
267
+ "On the JVZoo version keep only the JVZoo buy button, terms, privacy, support and in-page anchors.", ["jvzoo"], offpage)
268
+ if "warriorplus" in networks:
269
+ if "wplus_spdisclaimer" not in p.ids and "wplus_spdisclaimer" not in p.classes:
270
+ add("warriorplus_disclaimer", "blocker", "The WarriorPlus disclaimer element is missing.",
271
+ "Paste the offer's Sales Page Code: the wplus_spdisclaimer div directly under the buy button and the offer's o2/js script.", ["warriorplus"])
272
+ if not any("warriorplus.com/o2/js" in s for s in p.scripts):
273
+ add("warriorplus_script", "blocker", "The WarriorPlus offer script (warriorplus.com/o2/js/...) was not found.",
274
+ "Include the script from the offer's Sales Page Code; it fills the disclaimer and tracks affiliates.", ["warriorplus"])
275
+ if offpage:
276
+ add("warriorplus_offpage_links", "warning", "Links that leave the page; WarriorPlus asked us for WarriorPlus as the only way forward from the page.",
277
+ "Remove off-site links from the WarriorPlus version of the page, except terms, privacy and support.", ["warriorplus"], offpage)
278
+ if "clickbank" in networks:
279
+ if not ("clickbank is the retailer of products on this site" in low and "click sales" in low):
280
+ add("clickbank_disclaimer", "blocker", "ClickBank's retailer disclaimer is missing ('ClickBank is the retailer of products on this site... Click Sales, Inc.').",
281
+ "Paste ClickBank's disclaimer verbatim in the footer of the sales and thank-you pages.", ["clickbank"])
282
+ if "clkbank.com" not in hrefs:
283
+ add("clickbank_order_support", "blocker", "No link to ClickBank order support (clkbank.com).",
284
+ "Add 'For Order Support, please contact ClickBank' linking https://www.clkbank.com, next to your own product-support contact.", ["clickbank"])
285
+ if "digistore24" in networks:
286
+ days = {int(m.group(1) or m.group(4)) for m in REFUND_DAYS.finditer(text)}
287
+ if not days & {60, 90, 180}:
288
+ add("digistore24_return_period", "blocker", "No written 60-, 90- or 180-day refund period (Digistore24 Inc. requires one, at least 60 days for English products).",
289
+ "Add a plain-text refund paragraph with the same period as the product's return-policy setting.", ["digistore24"])
290
+ if _claims(re.compile(r"(make money|side hustle|earn(ed|ing)? (\$|up to)|\$\d[\d,]* (in|per|a|every) (day|week|month)|income (stream|of \$)|online business)", re.I), text) \
291
+ and "these earnings are not representative" not in low:
292
+ add("digistore24_earnings_disclaimer", "blocker", "Income-related page without Digistore24's earnings disclaimer.",
293
+ "Turn on 'Display earnings disclaimer' on the trust badges and embed them.", ["digistore24"])
294
+ if not any("digistore24" in (s or "").lower() for s in p.scripts + p.images):
295
+ add("digistore24_trust_badge", "warning", "No Digistore24 trust badge embed found (needed for Digistore24 Inc. products).",
296
+ "Create the Sales Page Badge and Footer under Conversion tools > Trust badges and paste their code.", ["digistore24"])
297
+
298
+ blockers = sum(1 for f in found if f["severity"] == "blocker")
299
+ return {
300
+ "url": final,
301
+ "title": " ".join(p.title.split())[:160],
302
+ "networks": networks,
303
+ "ready": blockers == 0,
304
+ "blockers": blockers,
305
+ "warnings": len(found) - blockers,
306
+ "findings": found,
307
+ "limits": "Reads the public HTML only: content drawn by JavaScript after load, pages behind a login and your checkout itself are not seen. A clean audit is not an approval.",
308
+ }