File size: 19,551 Bytes
bda5fb6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
#!/usr/bin/env python3
"""
Applies "real progress" plumbing to your backend:

    python apply_progress_patch.py [directory containing the three files]

Edits:  ai_visibility.py, seo_analyzer.py, app.py
- Every anchor must match EXACTLY ONCE (whitespace-tolerant). If any anchor
  fails in a file, that file is left untouched and the failures are listed.
- A .bak copy is written before a file is modified.
- Safe to re-run: files that already contain the marker are skipped.
"""
import re
import sys
import shutil
import textwrap
from pathlib import Path

MARKER = "# --- live progress (real, event-driven) ---"


class Patcher:
    def __init__(self, path):
        self.path = Path(path)
        self.src = self.path.read_text(encoding="utf-8")
        self.errors = []

    def sub(self, name, old, new):
        """Replace a block that starts at the beginning of a line.
        `old` is matched token-by-token (whitespace-insensitive).
        `new` is written at indent 0 and re-indented to the anchor's indent."""
        toks = old.split()
        pat = re.compile(r"(?m)^([ \t]*)" + r"\s+".join(re.escape(t) for t in toks))
        ms = list(pat.finditer(self.src))
        if len(ms) != 1:
            self.errors.append(f"[{self.path.name}] '{name}': matched {len(ms)}x (expected 1)")
            return
        m = ms[0]
        indent = m.group(1)
        block = textwrap.dedent(new).strip("\n")
        block = "\n".join((indent + ln) if ln.strip() else ln for ln in block.split("\n"))
        self.src = self.src[: m.start()] + block + self.src[m.end():]

    def save(self):
        if self.errors:
            print(f"\n✗ {self.path.name}: NOT modified")
            for e in self.errors:
                print("   ", e)
            return False
        shutil.copy(self.path, str(self.path) + ".bak")
        self.path.write_text(self.src, encoding="utf-8")
        print(f"✓ {self.path.name}: patched (backup: {self.path.name}.bak)")
        return True


# ----------------------------------------------------------------------------
# ai_visibility.py
# ----------------------------------------------------------------------------
AI_HELPERS = f'''
{MARKER}
# Each stage owns a slice of the 0-100 bar. progress = slice_start + (done/total) * slice_size,
# so the bar only moves when real work completes.
def _stage_ranges(observed=True):
    if observed:
        return {{"discover": (0, 8), "crawl": (8, 40), "scan": (40, 60), "analyze": (60, 95), "report": (95, 100)}}
    return {{"discover": (0, 10), "crawl": (10, 50), "scan": (50, 90), "analyze": (90, 90), "report": (90, 100)}}


def _evt(stage, done=0, total=1, message="", observed=True, **extra):
    lo, hi = _stage_ranges(observed)[stage]
    frac = (done / total) if total else 1
    return {{"stage": stage, "done": done, "total": total,
            "progress": round(lo + (hi - lo) * frac, 1), "message": message, **extra}}


async def _emit(cb, evt):
    """Never let progress reporting break the analysis."""
    if cb is None:
        return
    try:
        r = cb(evt)
        if asyncio.iscoroutine(r):
            await r
    except Exception:
        pass


def _get_anthropic_client():'''


def patch_ai_visibility(path):
    p = Patcher(path)
    if MARKER in p.src:
        print(f"- {p.path.name}: already patched, skipping")
        return True

    p.sub("helpers", "def _get_anthropic_client():", AI_HELPERS)

    p.sub(
        "observed signature",
        "async def analyze_observed_ai_visibility(brand_name, target_prompts=None, competitor_names=None, use_live_ai=False, brand_facts=None, engines=None):",
        "async def analyze_observed_ai_visibility(brand_name, target_prompts=None, competitor_names=None, use_live_ai=False, brand_facts=None, engines=None, progress_cb=None):",
    )

    p.sub(
        "observed engine loop",
        """per_engine_raw = {}
    for engine in available:
        raw_results = []
        for prompt in target_prompts:
            answer = await loop.run_in_executor(None, _call_engine_sync, engine, prompt)
            raw_results.append({"prompt": prompt, "answer": answer})
        per_engine_raw[engine] = raw_results

    per_engine_metrics = {}
    for engine, raw_results in per_engine_raw.items():
        valid = [r for r in raw_results if r["answer"] is not None]
        representation = await _check_representation_accuracy(brand_facts, valid, loop)
        per_engine_metrics[engine] = _compute_engine_metrics(brand_name, target_prompts, competitor_names, raw_results, representation)""",
        """
        total_steps = len(available) * len(target_prompts) + len(available)  # prompts + one accuracy check per engine
        done = 0
        per_engine_raw = {}
        for engine in available:
            raw_results = []
            for prompt in target_prompts:
                answer = await loop.run_in_executor(None, _call_engine_sync, engine, prompt)
                raw_results.append({"prompt": prompt, "answer": answer})
                done += 1
                await _emit(progress_cb, _evt(
                    "analyze", done, total_steps,
                    f"Asking {ENGINE_DISPLAY_NAMES[engine]}",
                    engine=engine, answered=answer is not None,
                ))
            per_engine_raw[engine] = raw_results

        per_engine_metrics = {}
        for engine, raw_results in per_engine_raw.items():
            valid = [r for r in raw_results if r["answer"] is not None]
            representation = await _check_representation_accuracy(brand_facts, valid, loop)
            per_engine_metrics[engine] = _compute_engine_metrics(brand_name, target_prompts, competitor_names, raw_results, representation)
            done += 1
            await _emit(progress_cb, _evt("analyze", done, total_steps, f"Checking {ENGINE_DISPLAY_NAMES[engine]} answers"))
        """,
    )

    p.sub("run signature", "include_raw_metrics=False,", "include_raw_metrics=False,\nprogress_cb=None,")

    p.sub(
        "discover + crawl",
        """urls = await discover_urls_parallel(base_url, max_pages)
    if not urls:
        urls = [base_url]

    playwright_data, failed_fetches = await fetch_all_pages_parallel(urls, max_concurrent)""",
        """
        obs = bool(auto_measure_observed)

        await _emit(progress_cb, _evt("discover", 0, 1, "Reading sitemap & internal links", obs))
        urls = await discover_urls_parallel(base_url, max_pages)
        if not urls:
            urls = [base_url]
        await _emit(progress_cb, _evt("discover", 1, 1, f"Found {len(urls)} pages", obs, urls_found=len(urls)))

        async def _on_page(done, total, page):
            await _emit(progress_cb, _evt(
                "crawl", done, total, "Crawling pages", obs,
                url=page.get("requested_url") or page.get("url"),
                ok=not page.get("fetch_failed"),
            ))

        playwright_data, failed_fetches = await fetch_all_pages_parallel(urls, max_concurrent, on_page=_on_page)
        """,
    )

    p.sub(
        "scan",
        """robots_txt = await _fetch_robots_txt(base_url)
    robots_bot_permissions = _parse_robots_txt(robots_txt, path="/") if robots_txt else None
    sitemap_present, llms_txt_present = await _check_sitemap_and_llms_txt(base_url)

    page_results = await asyncio.gather(*[
        analyze_page_ai_visibility(
            seo_data, domain, use_ai=use_ai,
            robots_bot_permissions=robots_bot_permissions,
            sitemap_present=sitemap_present, llms_txt_present=llms_txt_present,
        )
        for seo_data in playwright_data
    ])""",
        """
        scan_total = len(playwright_data) + 2  # pages + robots.txt + sitemap/llms.txt
        await _emit(progress_cb, _evt("scan", 0, scan_total, "Checking robots.txt", obs))
        robots_txt = await _fetch_robots_txt(base_url)
        robots_bot_permissions = _parse_robots_txt(robots_txt, path="/") if robots_txt else None
        await _emit(progress_cb, _evt("scan", 1, scan_total, "Checking sitemap & llms.txt", obs))
        sitemap_present, llms_txt_present = await _check_sitemap_and_llms_txt(base_url)
        await _emit(progress_cb, _evt("scan", 2, scan_total, "Scanning page content", obs))

        scanned = 2

        async def _analyze_one(seo_data):
            nonlocal scanned
            res = await analyze_page_ai_visibility(
                seo_data, domain, use_ai=use_ai,
                robots_bot_permissions=robots_bot_permissions,
                sitemap_present=sitemap_present, llms_txt_present=llms_txt_present,
            )
            scanned += 1
            await _emit(progress_cb, _evt("scan", scanned, scan_total, "Scanning page content", obs, url=seo_data.get("url")))
            return res

        page_results = await asyncio.gather(*[_analyze_one(d) for d in playwright_data])
        """,
    )

    p.sub(
        "auto-generate emit",
        "if auto_measure_observed:",
        """
        if auto_measure_observed:
            await _emit(progress_cb, _evt("analyze", 0, 1, "Inferring customer prompts from your homepage", True))
        """,
    )
    # NOTE: the patch above replaces only the `if` line; the original body that
    # follows stays attached to it (the emit is the first statement of the block).

    p.sub(
        "observed call",
        """observed = await analyze_observed_ai_visibility(
        brand_name=inferred_brand_name or domain, target_prompts=target_prompts,
        competitor_names=competitor_names, use_live_ai=use_live_ai_for_observed,
        brand_facts=brand_facts,
    )""",
        """
        observed = await analyze_observed_ai_visibility(
            brand_name=inferred_brand_name or domain, target_prompts=target_prompts,
            competitor_names=competitor_names, use_live_ai=use_live_ai_for_observed,
            brand_facts=brand_facts, progress_cb=progress_cb,
        )
        await _emit(progress_cb, _evt("report", 0, 1, "Building report", obs))
        """,
    )
    return p.save()


# ----------------------------------------------------------------------------
# seo_analyzer.py
# ----------------------------------------------------------------------------
SEO_HELPERS = f'''
{MARKER}
_SEO_STAGE_RANGES = {{"discover": (0, 8), "crawl": (8, 45), "scan": (45, 70), "analyze": (70, 95), "report": (95, 100)}}


def _evt(stage, done=0, total=1, message="", **extra):
    lo, hi = _SEO_STAGE_RANGES[stage]
    frac = (done / total) if total else 1
    return {{"stage": stage, "done": done, "total": total,
            "progress": round(lo + (hi - lo) * frac, 1), "message": message, **extra}}


async def _emit(cb, evt):
    """Never let progress reporting break the analysis."""
    if cb is None:
        return
    try:
        r = cb(evt)
        if asyncio.iscoroutine(r):
            await r
    except Exception:
        pass


async def fetch_robots_txt(base_url):'''


def patch_seo_analyzer(path):
    p = Patcher(path)
    if MARKER in p.src:
        print(f"- {p.path.name}: already patched, skipping")
        return True

    p.sub("helpers", "async def fetch_robots_txt(base_url):", SEO_HELPERS)

    # latent bug: callers unpack two values, this returned a bare list
    p.sub("empty-return fix", 'print("[fail] No URLs to fetch!")\n        return []',
          'print("[fail] No URLs to fetch!")\nreturn [], []')

    p.sub("fetch signature", "async def fetch_all_pages_parallel(urls, max_concurrent=1):",
          "async def fetch_all_pages_parallel(urls, max_concurrent=1, on_page=None):")

    p.sub(
        "fetch loop hook",
        "result = await task\n            results.append(result)",
        """
        result = await task
        results.append(result)
        if on_page and result is not None:
            try:
                r = on_page(i + 1, len(urls), result)
                if asyncio.iscoroutine(r):
                    await r
            except Exception:
                pass
        """,
    )

    p.sub("suggestions signature", "async def generate_all_page_suggestions_parallel(pages):",
          "async def generate_all_page_suggestions_parallel(pages, progress_cb=None):")

    p.sub(
        "suggestions tracker",
        'suggestions = {}\n\n    print(f"[ai] Generating AI suggestions for {len(pages)} pages in parallel...")',
        """
        suggestions = {}

        print(f"[ai] Generating AI suggestions for {len(pages)} pages in parallel...")

        _ai_done = {"n": 0}

        async def _tracked(coro):
            try:
                return await coro
            finally:
                _ai_done["n"] += 1
                await _emit(progress_cb, _evt("analyze", _ai_done["n"], len(pages), "Generating AI suggestions"))
        """,
    )
    p.sub("suggestions wrap", "task = generate_page_suggestions_async(page)",
          "task = _tracked(generate_page_suggestions_async(page))")
    p.sub("add_comprehensive signature", "async def add_comprehensive_suggestions_async(results):",
          "async def add_comprehensive_suggestions_async(results, progress_cb=None):")
    p.sub("add_comprehensive call", "suggestions_dict = await generate_all_page_suggestions_parallel(results)",
          "suggestions_dict = await generate_all_page_suggestions_parallel(results, progress_cb=progress_cb)")

    p.sub(
        "orchestrator signature",
        'async def run_seo_and_suggestions_async(base_url, max_pages=20, tmp_dir="/tmp", use_ai=True, max_concurrent=3):',
        'async def run_seo_and_suggestions_async(base_url, max_pages=20, tmp_dir="/tmp", use_ai=True, max_concurrent=3, progress_cb=None):',
    )
    p.sub("emit discover start", "# STEP 1: Discover URLs in parallel",
          '# STEP 1: Discover URLs in parallel\nawait _emit(progress_cb, _evt("discover", 0, 1, "Reading sitemap & internal links"))')
    p.sub("emit discover done", 'print(f"[info] Found {len(urls)} URLs to analyze")',
          'print(f"[info] Found {len(urls)} URLs to analyze")\n'
          'await _emit(progress_cb, _evt("discover", 1, 1, f"Found {len(urls)} pages", urls_found=len(urls)))')
    p.sub(
        "crawl hook",
        "playwright_data, failed_fetches = await fetch_all_pages_parallel(urls, max_concurrent)",
        """
        async def _on_page(done, total, page):
            await _emit(progress_cb, _evt(
                "crawl", done, total, "Crawling pages",
                url=page.get("requested_url") or page.get("url"),
                ok=not page.get("fetch_failed"),
            ))

        playwright_data, failed_fetches = await fetch_all_pages_parallel(urls, max_concurrent, on_page=_on_page)
        """,
    )
    p.sub(
        "scan emit",
        "results = await analyze_pages_parallel(playwright_data, domain)",
        """
        await _emit(progress_cb, _evt("scan", 0, 1, "Analyzing page content"))
        results = await analyze_pages_parallel(playwright_data, domain)
        await _emit(progress_cb, _evt("scan", 1, 1, "Page content analyzed"))
        """,
    )
    p.sub("ai suggestions call", "await add_comprehensive_suggestions_async(results)",
          "await add_comprehensive_suggestions_async(results, progress_cb=progress_cb)")
    p.sub("report emit", "# STEP 6: Save to CSV",
          '# STEP 6: Save to CSV\nawait _emit(progress_cb, _evt("report", 0, 1, "Building report"))')

    p.sub("full audit signature",
          "async def run_full_seo_audit(base_url, max_pages=20, use_ai=True, max_concurrent=3):",
          "async def run_full_seo_audit(base_url, max_pages=20, use_ai=True, max_concurrent=3, progress_cb=None):")
    p.sub("full audit call",
          'tmp_dir="/tmp",\n        use_ai=use_ai,\n        max_concurrent=max_concurrent,',
          'tmp_dir="/tmp",\nuse_ai=use_ai,\nmax_concurrent=max_concurrent,\nprogress_cb=progress_cb,')
    return p.save()


# ----------------------------------------------------------------------------
# app.py
# ----------------------------------------------------------------------------
APP_HELPERS = f'''
{MARKER}
async def _stream_job(make_coro):
    """Run an analysis coroutine and yield its progress events as they happen,
    then the final result. `make_coro(cb)` must return the awaitable."""
    q: asyncio.Queue = asyncio.Queue()

    async def cb(evt):
        await q.put(evt)

    task = asyncio.create_task(make_coro(cb))
    try:
        while not task.done() or not q.empty():
            try:
                evt = await asyncio.wait_for(q.get(), timeout=0.5)
            except asyncio.TimeoutError:
                continue
            yield {{"type": "progress", **evt}}
        result = await task
        if isinstance(result, dict) and result.get("status") == "error":
            yield {{"type": "error", "message": result.get("message", "Analysis failed")}}
        else:
            yield {{"type": "result", "data": json.loads(json.dumps(result, default=str))}}
    except Exception as e:
        yield {{"type": "error", "message": str(e)}}
    finally:
        if not task.done():
            task.cancel()


def _normalize_url(url):
    return url if str(url).startswith(("http://", "https://")) else f"https://{{url}}"


async def analyze_ai_stream(url, max_pages, max_concurrent, use_ai, measure_observed):
    async for msg in _stream_job(lambda cb: run_ai_visibility_analysis(
        base_url=_normalize_url(url), max_pages=int(max_pages), max_concurrent=int(max_concurrent),
        use_ai=use_ai, auto_measure_observed=bool(measure_observed), progress_cb=cb,
    )):
        yield msg


async def analyze_seo_stream(url, max_pages, max_concurrent, use_ai):
    async for msg in _stream_job(lambda cb: run_full_seo_audit(
        base_url=_normalize_url(url), max_pages=int(max_pages),
        use_ai=use_ai, max_concurrent=int(max_concurrent), progress_cb=cb,
    )):
        yield msg


# ---- Gradio Interface ----'''

APP_WIRING = '''
# Streaming endpoints (hidden; consumed by the frontend via @gradio/client)
with demo:
    ai_stream_out = gr.JSON(visible=False)
    ai_stream_btn = gr.Button("stream-ai", visible=False)
    ai_stream_btn.click(
        fn=analyze_ai_stream,
        inputs=[url_input, max_pages_input, max_concurrent_input, use_ai_check, measure_observed_check],
        outputs=ai_stream_out,
        api_name="analyze_ai_stream",
    )
    seo_stream_out = gr.JSON(visible=False)
    seo_stream_btn = gr.Button("stream-seo", visible=False)
    seo_stream_btn.click(
        fn=analyze_seo_stream,
        inputs=[url_input, max_pages_input, max_concurrent_input, use_ai_check],
        outputs=seo_stream_out,
        api_name="analyze_seo_stream",
    )

if __name__ == "__main__":'''


def patch_app(path):
    p = Patcher(path)
    if MARKER in p.src:
        print(f"- {p.path.name}: already patched, skipping")
        return True
    p.sub("json import", "import gradio as gr", "import gradio as gr\nimport json")
    p.sub("stream helpers", "# ---- Gradio Interface ----", APP_HELPERS)
    p.sub("stream wiring", 'if __name__ == "__main__":', APP_WIRING)
    return p.save()


if __name__ == "__main__":
    root = Path(sys.argv[1] if len(sys.argv) > 1 else ".")
    ok = True
    for fn, name in ((patch_ai_visibility, "ai_visibility.py"),
                     (patch_seo_analyzer, "seo_analyzer.py"),
                     (patch_app, "app.py")):
        f = root / name
        if not f.exists():
            print(f"✗ {name}: not found in {root.resolve()}")
            ok = False
            continue
        ok = fn(f) and ok
    sys.exit(0 if ok else 1)