seoagents 0.2.0__py3-none-any.whl

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.
seoagent/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """SEO Agent: an open-source SEO agent for link building, exposed as an MCP server.
2
+
3
+ Free mode serves the bundled 25-site library locally and builds ten links, one of them on a DA 90+ site. With an API key it connects to the hosted engine,
4
+ which adds the full 1,245-site library, campaign planning, automatic building and verification.
5
+ """
6
+
7
+ __version__ = "0.2.0"
File without changes
@@ -0,0 +1,296 @@
1
+ """Playwright helpers shared by playbooks: session handling and field finding heuristics."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ from contextlib import contextmanager
7
+ from typing import Iterator, Optional
8
+
9
+ from playwright.sync_api import Locator, Page, TimeoutError as PWTimeout, sync_playwright
10
+
11
+ URL_HINTS = ("url", "link", "http", "website", "web address", "address", "long")
12
+ SUBMIT_HINTS = ("shorten", "submit", "add", "create", "go", "generate", "make", "save", "post", "send")
13
+ COOKIE_HINTS = ("accept", "agree", "got it", "allow", "ok", "consent", "i understand")
14
+
15
+
16
+ @contextmanager
17
+ def session(headless: bool = True, viewport: tuple[int, int] = (1366, 900)) -> Iterator[Page]:
18
+ with sync_playwright() as p:
19
+ browser = p.chromium.launch(headless=headless)
20
+ ctx = browser.new_context(
21
+ viewport={"width": viewport[0], "height": viewport[1]},
22
+ user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
23
+ locale="en-US",
24
+ )
25
+ page = ctx.new_page()
26
+ page.set_default_timeout(15000)
27
+ try:
28
+ yield page
29
+ finally:
30
+ ctx.close()
31
+ browser.close()
32
+
33
+
34
+ DEFAULT_PROFILE = os.path.join(os.path.expanduser("~"), ".linkengine", "profiles")
35
+
36
+
37
+ def profile_dir(name: str = "default") -> str:
38
+ return os.environ.get("LINKENGINE_PROFILES") or os.path.join(DEFAULT_PROFILE, name)
39
+
40
+
41
+ @contextmanager
42
+ def signed_in_session(name: str = "default", headless: bool = False, viewport: tuple[int, int] = (1300, 900),
43
+ url: str = "") -> Iterator[Page]:
44
+ """A browser the person can sign into, and that stays signed in.
45
+
46
+ Two things here are not cosmetic. It runs **real Chrome**, not Playwright's bundled Chromium, and it turns off
47
+ the flag that makes the page see `navigator.webdriver === true`. Google refuses to complete a sign-in in a
48
+ browser advertising itself as automated -- deliberately, to stop phishing -- and since some sites route their
49
+ own verification through Google, a person can end up unable to sign into their own account at all. Nothing
50
+ here defeats a security check: the human types their own password into their own account. It stops the browser
51
+ falsely announcing itself as a robot while they do it.
52
+
53
+ The profile is kept on disk, so this is asked for once and every later build starts already signed in."""
54
+ d = profile_dir(name)
55
+ os.makedirs(d, exist_ok=True)
56
+ with sync_playwright() as p:
57
+ ctx = p.chromium.launch_persistent_context(
58
+ d,
59
+ channel="chrome",
60
+ headless=headless,
61
+ viewport={"width": viewport[0], "height": viewport[1]},
62
+ locale="en-US",
63
+ args=["--disable-blink-features=AutomationControlled",
64
+ "--no-default-browser-check", "--no-first-run",
65
+ f"--window-size={viewport[0] + 40},{viewport[1] + 120}"],
66
+ ignore_default_args=["--enable-automation"],
67
+ )
68
+ page = ctx.pages[0] if ctx.pages else ctx.new_page()
69
+ page.set_default_timeout(30000)
70
+ if url:
71
+ try:
72
+ page.goto(url, wait_until="domcontentloaded")
73
+ except Exception:
74
+ pass
75
+ try:
76
+ yield page
77
+ finally:
78
+ ctx.close()
79
+
80
+
81
+ def signed_in_to(page: Page, host: str) -> bool:
82
+ """Whether the profile holds a live session for this host, read from the cookie the site itself sets."""
83
+ marker = {"facebook.com": r"c_user=\d+", "x.com": r"auth_token=", "linkedin.com": r"li_at=",
84
+ "reddit.com": r"reddit_session=", "medium.com": r"sid="}.get(host, r"session")
85
+ try:
86
+ return bool(page.evaluate(f"() => !!document.cookie.match(/{marker}/)"))
87
+ except Exception:
88
+ return False
89
+
90
+
91
+ def quoted_names(texts: list[str]) -> list[str]:
92
+ """Button and field names the guide quotes, e.g. Click "Shorten URL"."""
93
+ out: list[str] = []
94
+ for t in texts:
95
+ for m in re.findall(r'[“"]([^”"]{2,40})[”"]', t):
96
+ m = m.strip()
97
+ if m and m.lower() not in (x.lower() for x in out):
98
+ out.append(m)
99
+ return out
100
+
101
+
102
+ def dismiss_banners(page: Page) -> None:
103
+ for hint in COOKIE_HINTS:
104
+ try:
105
+ btn = page.get_by_role("button", name=re.compile(rf"^\s*{re.escape(hint)}", re.I)).first
106
+ if btn.is_visible(timeout=800):
107
+ btn.click(timeout=2000)
108
+ page.wait_for_timeout(300)
109
+ return
110
+ except Exception:
111
+ continue
112
+
113
+
114
+ # First-run and welcome dialogs sit on top of everything and swallow the click you meant to make. They are not
115
+ # consent banners -- the wording is different and so is the button -- so they need their own pass.
116
+ DIALOG_BUTTONS = ("Use Page", "Not now", "Not Now", "Skip", "Skip for now", "Maybe later", "Got it", "OK",
117
+ "Okay", "Done", "Close", "Continue", "Dismiss", "No thanks")
118
+
119
+
120
+ def dismiss_dialogs(page: Page, rounds: int = 3) -> list[str]:
121
+ """Close whatever modal is covering the page, and say what was closed.
122
+
123
+ A welcome dialog looks harmless in a screenshot and is fatal to automation: every control behind it is
124
+ present in the DOM, visible to a query, and impossible to click. Returns the buttons it pressed so a failure
125
+ later can be read against what was in the way."""
126
+ closed: list[str] = []
127
+ for _ in range(rounds):
128
+ hit = False
129
+ for name in DIALOG_BUTTONS:
130
+ try:
131
+ btn = page.get_by_role("button", name=re.compile(rf"^\s*{re.escape(name)}\s*$", re.I)).first
132
+ if btn.is_visible(timeout=600):
133
+ btn.click(timeout=2500)
134
+ closed.append(name)
135
+ page.wait_for_timeout(1200)
136
+ hit = True
137
+ break
138
+ except Exception:
139
+ continue
140
+ if not hit:
141
+ try: # a dialog with only an X, or one that closes on Escape
142
+ x = page.locator("div[role=dialog] [aria-label='Close']").first
143
+ if x.is_visible(timeout=600):
144
+ x.click(timeout=2000); closed.append("Close"); page.wait_for_timeout(1000); continue
145
+ except Exception:
146
+ pass
147
+ break
148
+ return closed
149
+
150
+
151
+ def _attr_blob(loc: Locator) -> str:
152
+ try:
153
+ return loc.evaluate(
154
+ "e => [e.type, e.name, e.id, e.placeholder, e.getAttribute('aria-label'), e.title, "
155
+ "(e.labels && e.labels[0] ? e.labels[0].innerText : '')].join(' ').toLowerCase()"
156
+ )
157
+ except Exception:
158
+ return ""
159
+
160
+
161
+ def find_url_input(page: Page) -> Optional[Locator]:
162
+ """The most likely field for a URL: typed url first, then hinted names, then the largest visible text input."""
163
+ typed = page.locator("input[type=url]")
164
+ for i in range(typed.count()):
165
+ if typed.nth(i).is_visible():
166
+ return typed.nth(i)
167
+ cands = page.locator("input[type=text], input:not([type]), textarea")
168
+ scored: list[tuple[int, Locator]] = []
169
+ for i in range(min(cands.count(), 40)):
170
+ loc = cands.nth(i)
171
+ try:
172
+ if not loc.is_visible():
173
+ continue
174
+ except Exception:
175
+ continue
176
+ blob = _attr_blob(loc)
177
+ s = sum(3 for h in URL_HINTS if h in blob)
178
+ if "search" in blob or "email" in blob or "password" in blob:
179
+ s -= 5
180
+ try:
181
+ box = loc.bounding_box()
182
+ if box:
183
+ s += min(int(box["width"] // 100), 4)
184
+ except Exception:
185
+ pass
186
+ scored.append((s, loc))
187
+ scored.sort(key=lambda t: -t[0])
188
+ return scored[0][1] if scored and scored[0][0] > 0 else (scored[0][1] if scored else None)
189
+
190
+
191
+ def find_submit(page: Page, near: Optional[Locator] = None, names: Optional[list[str]] = None) -> Optional[Locator]:
192
+ """A submit control: the input's own form submit first (it is the one wired to the field), then the guide's
193
+ quoted button name, then generic verbs. Guide names come second because sites often repeat them on
194
+ decorative call-to-action links elsewhere on the page."""
195
+ if near is not None:
196
+ try:
197
+ form = near.locator("xpath=ancestor::form[1]")
198
+ if form.count():
199
+ subs = form.locator("button[type=submit], input[type=submit], button:not([type]), input[type=image], button[type=button]")
200
+ ranked: list[tuple[int, Locator]] = []
201
+ wanted = [n.lower() for n in (names or [])]
202
+ for i in range(min(subs.count(), 8)):
203
+ sub = subs.nth(i)
204
+ if not sub.is_visible(timeout=300):
205
+ continue
206
+ try:
207
+ label = sub.evaluate("e => (e.innerText || e.value || e.getAttribute('aria-label') || '').trim().toLowerCase()")
208
+ except Exception:
209
+ label = ""
210
+ rank = 0
211
+ if label:
212
+ rank = 1
213
+ if any(h in label for h in SUBMIT_HINTS) or any(w in label for w in wanted):
214
+ rank = 2
215
+ ranked.append((rank, sub))
216
+ if ranked:
217
+ ranked.sort(key=lambda t: -t[0])
218
+ return ranked[0][1]
219
+ except Exception:
220
+ pass
221
+ for name in names or []:
222
+ for role in ("button", "link"):
223
+ try:
224
+ b = page.get_by_role(role, name=re.compile(rf"^\s*{re.escape(name)}\s*$", re.I)).first
225
+ if b.is_visible(timeout=500):
226
+ return b
227
+ except Exception:
228
+ pass
229
+ for hint in SUBMIT_HINTS:
230
+ try:
231
+ b = page.get_by_role("button", name=re.compile(hint, re.I)).first
232
+ if b.is_visible(timeout=300):
233
+ return b
234
+ except Exception:
235
+ pass
236
+ try:
237
+ b = page.locator("input[type=submit]").first
238
+ if b.is_visible(timeout=300):
239
+ return b
240
+ except Exception:
241
+ pass
242
+ return None
243
+
244
+
245
+ def visible_urls(page: Page) -> list[str]:
246
+ """Every http(s) URL currently shown as a link href, input value or bare text on the page."""
247
+ try:
248
+ return page.evaluate(
249
+ """() => {
250
+ const out = new Set();
251
+ for (const a of document.querySelectorAll('a[href]')) out.add(a.href);
252
+ for (const i of document.querySelectorAll('input, textarea')) { const v = (i.value||'').trim(); if (/^https?:\\/\\//i.test(v)) out.add(v); }
253
+ const re = /https?:\\/\\/[^\\s"'<>]+/g; const m = document.body.innerText.match(re) || [];
254
+ for (const x of m) out.add(x.replace(/[).,]+$/, ''));
255
+ return [...out];
256
+ }"""
257
+ )
258
+ except Exception:
259
+ return []
260
+
261
+
262
+ def bot_gate(page: Page, check_empty: bool = True) -> Optional[str]:
263
+ """Why an automated browser cannot proceed here, or None: a challenge page, a captcha widget, or nothing rendered.
264
+ The empty-page signal only means anything on first load; after a submit a short confirmation page is normal."""
265
+ try:
266
+ title = (page.title() or "").lower()
267
+ if "just a moment" in title or "attention required" in title or "access denied" in title:
268
+ return "bot challenge page"
269
+ body = (page.inner_text("body") or "")[:2000].lower()
270
+ if "you have been blocked" in body or "verify you are human" in body and "cloudflare" in body:
271
+ return "blocked by the site's firewall"
272
+ has = page.evaluate(
273
+ "() => ({turn: !!document.querySelector('input[name=cf-turnstile-response], .cf-turnstile, iframe[src*=challenges]'),"
274
+ " recap: !!document.querySelector('.g-recaptcha, iframe[src*=recaptcha], .h-captcha, iframe[src*=hcaptcha]'),"
275
+ " empty: (document.body ? document.body.innerText.trim().length : 0) < 20})"
276
+ )
277
+ if has["turn"]:
278
+ return "Cloudflare Turnstile on the form"
279
+ if has["recap"]:
280
+ return "captcha on the form"
281
+ if has["empty"] and check_empty:
282
+ return "page rendered empty for an automated browser"
283
+ except Exception:
284
+ return None
285
+ return None
286
+
287
+
288
+ def safe_goto(page: Page, url: str) -> bool:
289
+ try:
290
+ page.goto(url, wait_until="domcontentloaded", timeout=30000)
291
+ page.wait_for_timeout(1200)
292
+ return True
293
+ except PWTimeout:
294
+ return False
295
+ except Exception:
296
+ return False
@@ -0,0 +1,55 @@
1
+ """Runs one link job on the user's machine: choose a playbook, drive a local browser, save proof, return a JobResult.
2
+ Login-free sites only; every other method is followed by the assistant itself from get_method."""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ import uuid
8
+
9
+ from ..models import JobResult, Site
10
+ from .browser import bot_gate, session
11
+ from .playbooks import open_submit, shortener
12
+
13
+ RUNS_DIR = os.environ.get("SEOAGENT_RUNS", os.path.join(os.path.expanduser("~"), ".seoagent", "runs"))
14
+ PLAYBOOKS = {"url_shortener": "shortener", "bookmark_submit": "open_submit"}
15
+
16
+
17
+ def can_auto_build(site: Site) -> bool:
18
+ return site.tier == "A" and site.method in PLAYBOOKS
19
+
20
+
21
+ def wait_for_human(page, seconds: int = 180) -> bool:
22
+ """Headed mode: give the person at the screen time to clear a captcha. True when the gate is gone."""
23
+ deadline = time.time() + seconds
24
+ while time.time() < deadline:
25
+ if not bot_gate(page):
26
+ return True
27
+ page.wait_for_timeout(2000)
28
+ return False
29
+
30
+
31
+ def run_job(site: Site, target_url: str, anchor_text: str = "", *, headless: bool = True, description: str = "") -> JobResult:
32
+ if not can_auto_build(site):
33
+ return JobResult(slug=site.slug, target_url=target_url, anchor_text=anchor_text, status="manual",
34
+ notes=f"{site.method_label} needs an account; follow get_method in your own browser, then verify_link and log_link")
35
+ job_id = f"{int(time.time())}-{site.slug}-{uuid.uuid4().hex[:6]}"
36
+ out_dir = os.path.join(RUNS_DIR, job_id)
37
+ os.makedirs(out_dir, exist_ok=True)
38
+ proof = os.path.join(out_dir, "proof.png")
39
+ try:
40
+ with session(headless=headless) as page:
41
+ def go():
42
+ if PLAYBOOKS[site.method] == "shortener":
43
+ return shortener.run(page, site, target_url, proof)
44
+ return open_submit.run(page, site, target_url, anchor_text or site.name, proof, description)
45
+ r = go()
46
+ if r.get("status") == "gated" and not headless and wait_for_human(page):
47
+ r = go()
48
+ except Exception as e: # a playbook crash is a failed job, never a crashed server
49
+ msg = f"{type(e).__name__}: {e}"[:300]
50
+ if "Executable doesn't exist" in msg or "playwright install" in msg:
51
+ msg = "the browser is not installed yet: run `playwright install chromium` once, then try again"
52
+ r = {"status": "failed", "notes": msg}
53
+ return JobResult(slug=site.slug, target_url=target_url, anchor_text=anchor_text, status=r["status"],
54
+ live_url=r.get("live_url"), proof_path=proof if os.path.exists(proof) else None, notes=r.get("notes", ""),
55
+ gate=r.get("gate"))
File without changes
@@ -0,0 +1,79 @@
1
+ """Tier A playbook: open submission forms (no account). Fill URL, title and description, submit, verify."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from typing import Optional
6
+
7
+ from playwright.sync_api import Locator, Page
8
+
9
+ from ...models import Site
10
+ from ...gates import gate_from_reason
11
+ from ..browser import dismiss_banners, find_submit, find_url_input, quoted_names, safe_goto, visible_urls
12
+
13
+
14
+ def _find_text(page: Page, hints: tuple[str, ...], exclude: Optional[Locator] = None) -> Optional[Locator]:
15
+ cands = page.locator("input[type=text], input:not([type]), textarea")
16
+ for i in range(min(cands.count(), 40)):
17
+ loc = cands.nth(i)
18
+ try:
19
+ if not loc.is_visible():
20
+ continue
21
+ blob = loc.evaluate("e => [e.name, e.id, e.placeholder, e.getAttribute('aria-label')].join(' ').toLowerCase()")
22
+ except Exception:
23
+ continue
24
+ if any(h in blob for h in hints):
25
+ if exclude is not None:
26
+ try:
27
+ if loc.evaluate("e => e") == exclude.evaluate("e => e"):
28
+ continue
29
+ except Exception:
30
+ pass
31
+ return loc
32
+ return None
33
+
34
+
35
+ def run(page: Page, site: Site, target_url: str, anchor_text: str, proof_path: str, description: str = "") -> dict:
36
+ if not safe_goto(page, site.url):
37
+ return {"status": "failed", "notes": "site did not load"}
38
+ dismiss_banners(page)
39
+ names = quoted_names([s.text for s in site.steps])
40
+ # follow an "Add link / Submit" entry point if the form is not on the landing page
41
+ if find_url_input(page) is None:
42
+ for name in names + ["Submit", "Add link", "Add URL", "Add site", "Submit URL", "Suggest"]:
43
+ try:
44
+ link = page.get_by_role("link", name=re.compile(re.escape(name), re.I)).first
45
+ if link.is_visible(timeout=400):
46
+ link.click(); page.wait_for_timeout(1500); break
47
+ except Exception:
48
+ continue
49
+ field = find_url_input(page)
50
+ if field is None:
51
+ page.screenshot(path=proof_path)
52
+ return {"status": "failed", "notes": "no URL input found"}
53
+ field.fill(target_url)
54
+ title = _find_text(page, ("title", "name", "subject"), exclude=field)
55
+ if title:
56
+ title.fill(anchor_text)
57
+ desc = _find_text(page, ("desc", "summary", "about", "comment", "text"), exclude=field)
58
+ if desc:
59
+ desc.fill(description or f"{anchor_text} - {target_url}")
60
+ btn = find_submit(page, near=field, names=names)
61
+ if btn is not None:
62
+ btn.click()
63
+ else:
64
+ field.press("Enter")
65
+ page.wait_for_timeout(3500)
66
+ page.screenshot(path=proof_path)
67
+ body = ""
68
+ try:
69
+ body = page.inner_text("body").lower()
70
+ except Exception:
71
+ pass
72
+ if "captcha" in body or "verify you are human" in body:
73
+ return {"status": "gated", "notes": "captcha shown after submit", "gate": gate_from_reason("captcha on the form", site.name).model_dump()}
74
+ urls = visible_urls(page)
75
+ if any(target_url.rstrip("/") in u for u in urls) or target_url.rstrip("/").lower() in body:
76
+ return {"status": "placed", "live_url": page.url, "notes": "target URL visible on result page"}
77
+ if any(w in body for w in ("thank", "submitted", "pending", "review", "approved", "success")):
78
+ return {"status": "placed", "live_url": page.url, "notes": "submission acknowledged; listing may await moderation"}
79
+ return {"status": "failed", "notes": "submitted but target URL not found on result page"}
@@ -0,0 +1,92 @@
1
+ """Tier A playbook: URL shorteners. Paste the target, submit, capture the short link, verify it resolves."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from urllib.parse import urlparse
6
+
7
+ from playwright.sync_api import Page
8
+
9
+ from ...models import Site
10
+ from ...gates import gate_from_reason
11
+ from ..browser import bot_gate, dismiss_banners, find_submit, find_url_input, quoted_names, safe_goto, visible_urls
12
+
13
+ NOT_A_SHORT_LINK = re.compile(r"/(human|captcha|login|signin|signup|register|create|preview|stats?|info|terms|privacy|about|contact|api|blog)\b", re.I)
14
+
15
+
16
+ def _host(u: str) -> str:
17
+ return urlparse(u).netloc.lower().removeprefix("www.")
18
+
19
+
20
+ def _same_site(u: str, domain: str) -> bool:
21
+ host = _host(u)
22
+ return host == domain or host.endswith("." + domain) or domain.endswith("." + host)
23
+
24
+
25
+ def pick_short_link(before: set[str], after: list[str], site_domain: str, site_url: str, target_url: str) -> list[str]:
26
+ """Candidate short links among URLs that appeared after submitting, best first.
27
+ A short link lives on the shortener's own domain, has a short opaque path, and does not carry the target as a
28
+ query parameter or point at a utility page."""
29
+ tgt = target_url.rstrip("/").lower()
30
+ cands = []
31
+ for u in after:
32
+ if u in before or not _same_site(u, site_domain):
33
+ continue
34
+ if u.rstrip("/") == site_url.rstrip("/") or tgt in u.lower():
35
+ continue
36
+ p = urlparse(u)
37
+ segs = [s for s in p.path.split("/") if s]
38
+ if not segs or len(segs) > 2 or p.query or NOT_A_SHORT_LINK.search(p.path):
39
+ continue
40
+ if not re.fullmatch(r"[A-Za-z0-9_\-]{2,32}", segs[-1]):
41
+ continue
42
+ cands.append(u)
43
+ cands.sort(key=lambda u: (len(urlparse(u).path.strip("/").split("/")), len(u)))
44
+ return cands
45
+
46
+
47
+ def verify(page: Page, short: str, target_url: str) -> bool:
48
+ """The short link resolves to the target, or shows an interstitial that references it."""
49
+ try:
50
+ resp = page.request.get(short, max_redirects=5, timeout=15000)
51
+ if _host(resp.url) == _host(target_url):
52
+ return True
53
+ body = resp.text()[:200000]
54
+ return target_url.rstrip("/") in body
55
+ except Exception:
56
+ return False
57
+
58
+
59
+ def run(page: Page, site: Site, target_url: str, proof_path: str) -> dict:
60
+ if not safe_goto(page, site.url):
61
+ return {"status": "failed", "notes": "site did not load"}
62
+ dismiss_banners(page)
63
+ gate = bot_gate(page)
64
+ if gate:
65
+ page.screenshot(path=proof_path)
66
+ return {"status": "gated", "notes": f"needs a human: {gate}", "gate": gate_from_reason(gate, site.name).model_dump()}
67
+ field = find_url_input(page)
68
+ if field is None:
69
+ page.screenshot(path=proof_path)
70
+ return {"status": "failed", "notes": "no URL input found"}
71
+ before = set(visible_urls(page))
72
+ field.click()
73
+ field.fill(target_url)
74
+ names = quoted_names([s.text for s in site.steps])
75
+ btn = find_submit(page, near=field, names=names)
76
+ if btn is not None:
77
+ btn.click()
78
+ else:
79
+ field.press("Enter")
80
+ page.wait_for_timeout(3500)
81
+ gate = bot_gate(page)
82
+ if gate:
83
+ page.screenshot(path=proof_path)
84
+ return {"status": "gated", "notes": f"needs a human: {gate} after submitting", "gate": gate_from_reason(gate, site.name).model_dump()}
85
+ cands = pick_short_link(before, visible_urls(page), site.domain, site.url, target_url)
86
+ page.screenshot(path=proof_path)
87
+ if not cands:
88
+ return {"status": "failed", "notes": "submitted but no short link appeared"}
89
+ for short in cands[:3]:
90
+ if verify(page, short, target_url):
91
+ return {"status": "placed", "live_url": short, "notes": "short link verified"}
92
+ return {"status": "unverified", "live_url": cands[0], "notes": "short link captured; could not confirm it resolves to the target (interstitial or script redirect)"}