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 +7 -0
- seoagent/agent/__init__.py +0 -0
- seoagent/agent/browser.py +296 -0
- seoagent/agent/executor.py +55 -0
- seoagent/agent/playbooks/__init__.py +0 -0
- seoagent/agent/playbooks/open_submit.py +79 -0
- seoagent/agent/playbooks/shortener.py +92 -0
- seoagent/data/free-sites.json +1997 -0
- seoagent/data/library_stats.json +35 -0
- seoagent/data/playbooks.json +17 -0
- seoagent/data/teasers.json +882 -0
- seoagent/free.py +95 -0
- seoagent/gates.py +60 -0
- seoagent/local.py +60 -0
- seoagent/media_set.py +373 -0
- seoagent/media_set_pace.py +216 -0
- seoagent/media_set_run.py +552 -0
- seoagent/models.py +43 -0
- seoagent/pages_run.py +446 -0
- seoagent/pages_site.py +190 -0
- seoagent/server.py +586 -0
- seoagent/verify.py +94 -0
- seoagents-0.2.0.dist-info/METADATA +262 -0
- seoagents-0.2.0.dist-info/RECORD +27 -0
- seoagents-0.2.0.dist-info/WHEEL +4 -0
- seoagents-0.2.0.dist-info/entry_points.txt +2 -0
- seoagents-0.2.0.dist-info/licenses/LICENSE +21 -0
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)"}
|