bulk-link-auditor / report_generator.py
vijaykumaredstellar's picture
Upload 6 files
7f10996 verified
"""
HTML Report Generator
Produces the interactive accordion-based light-theme audit report.
"""
import html as html_module
from urllib.parse import urlparse
from datetime import datetime
def esc(text):
return html_module.escape(str(text)) if text else ""
def short_url(url, domain="edstellar.com"):
parsed = urlparse(str(url))
if domain in parsed.netloc:
return parsed.path + (('?' + parsed.query) if parsed.query else '')
host = parsed.netloc.replace('www.', '')
path = parsed.path
if len(path) > 50:
path = path[:25] + '...' + path[-20:]
return f"{host}{path}"
def badge(text, cls):
return f'<span class="badge {cls}">{esc(text)}</span>'
def render_link_entry(link, domain="edstellar.com", show_flags=True):
has_issues = link['link_status'] in ('Broken',) or link.get('flags')
is_redirect = link['link_status'] == 'Redirect'
cls = 's-issue' if (has_issues or is_redirect) else 's-ok'
if link['link_status'] == 'Broken':
status_tag = f'<span class="tag-sm issue">{esc(link["status_code"])} BROKEN</span>'
elif link['link_status'] == 'Redirect':
status_tag = f'<span class="tag-sm issue">{esc(link["status_code"])} Redirect</span>'
else:
status_tag = f'<span class="tag-sm ok">{esc(link["status_code"])}</span>'
has_follow_flag = any('Dofollow' in f or 'Nofollow' in f for f in link.get('flags', []))
if has_follow_flag:
follow_tag = f'<span class="tag-sm issue">{link["follow"]} ⚠</span>'
else:
follow_tag = f'<span class="tag-sm ok">{link["follow"]} βœ“</span>'
out = f'<div class="le {cls}">'
out += f'<div class="le-url">{esc(short_url(link["url"], domain))}</div>'
out += f'<div class="le-tags">{status_tag}{follow_tag}</div>'
out += f'<div class="le-anchor">Anchor: <b>{esc(link["anchor"])}</b></div>'
if link.get('redirect_url'):
out += f'<div class="le-redir">β†’ {esc(short_url(link["redirect_url"], domain))}</div>'
out += f'<div class="le-location">πŸ“ {esc(link["location"])}</div>'
if show_flags:
for flag in link.get('flags', []):
out += f'<div class="le-issue">⚠ {esc(flag)}</div>'
out += '</div>'
return out
def render_accordion(collapsed_html, details_html):
return f'''<td class="acc-cell" onclick="toggleAcc(this)">
<div class="acc-collapsed"><div class="acc-chevron">β–Ά</div><div class="acc-summary">{collapsed_html}</div></div>
<div class="acc-details">{details_html}</div>
</td>'''
def generate_report(results, orphan_pages, domain="edstellar.com"):
now = datetime.now().strftime("%b %d, %Y %H:%M")
total_pages = len(results)
total_int = sum(r['int_count'] for r in results)
total_ext = sum(r['ext_count'] for r in results)
total_broken = sum(r['broken_int_count'] + r['broken_ext_count'] for r in results)
total_redirects = sum(r['redirect_int_count'] + r['redirect_ext_count'] for r in results)
total_flags = sum(r['follow_flag_count'] for r in results)
total_dups = sum(r['duplicate_count'] for r in results)
total_sug = sum(len(r['suggestions']) for r in results)
total_orphan = len(orphan_pages)
rows_html = ""
for idx, r in enumerate(results, 1):
if r['error']:
rows_html += f'''<tr><td class="cell-url"><div class="row-num">#{idx}</div>{esc(short_url(r["url"], domain))}</td>
<td colspan="15" style="color:var(--red);vertical-align:middle;">❌ Error: {esc(r["error"])}</td></tr>'''
continue
is_orphan = r['url'].rstrip('/').split('?')[0] in orphan_pages
# Internal Links
int_badges = ""
if r['int_count'] == 0:
int_badges = badge('0 Links ⚠', 'issue')
else:
ok_count = r['int_count'] - r['broken_int_count'] - r['redirect_int_count']
if ok_count > 0: int_badges += badge(f'{ok_count} Active', 'ok')
if r['broken_int_count'] > 0: int_badges += badge(f'{r["broken_int_count"]} Broken', 'issue')
if r['redirect_int_count'] > 0: int_badges += badge(f'{r["redirect_int_count"]} Redirect', 'issue')
int_details = "".join(render_link_entry(l, domain) for l in r['internal_links'])
if not int_details:
int_details = '<div style="font-size:11px;color:var(--red);">No internal links found in body content.</div>'
# External Links
ext_badges = ""
if r['ext_count'] == 0:
ext_badges = badge('0 Links', 'neutral')
else:
if r['broken_ext_count'] > 0: ext_badges += badge(f'{r["broken_ext_count"]} Broken', 'issue')
if r['redirect_ext_count'] > 0: ext_badges += badge(f'{r["redirect_ext_count"]} Redirect', 'issue')
ext_df_count = sum(1 for l in r['external_links'] if l['follow'] == 'Dofollow')
if ext_df_count > 0: ext_badges += badge(f'{ext_df_count} Dofollow ⚠', 'issue')
ok_ext = r['ext_count'] - r['broken_ext_count'] - r['redirect_ext_count']
if ok_ext > 0 and not r['broken_ext_count'] and not r['redirect_ext_count'] and not ext_df_count:
ext_badges += badge(f'{ok_ext} Active', 'ok')
ext_details = "".join(render_link_entry(l, domain) for l in r['external_links'])
if not ext_details:
ext_details = '<div style="font-size:11px;color:var(--text-dim);">No external links in body content.</div>'
# Follow Flags
int_nf_flags = [l for l in r['internal_links'] if l['follow'] == 'Nofollow']
ext_df_flags_list = [l for l in r['external_links'] if l['follow'] == 'Dofollow']
flag_badges = ""
if int_nf_flags: flag_badges += badge(f'{len(int_nf_flags)} Int. Nofollow ⚠', 'issue')
if ext_df_flags_list: flag_badges += badge(f'{len(ext_df_flags_list)} Ext. Dofollow ⚠', 'issue')
if not flag_badges: flag_badges = badge('βœ“ No Flags', 'ok')
flag_details = "".join(render_link_entry(l, domain, show_flags=True) for l in int_nf_flags + ext_df_flags_list)
if not flag_details:
flag_details = '<div style="font-size:11px;color:var(--green);">All internal=Dofollow βœ“ and external=Nofollow βœ“</div>'
# Broken / Redirect
bi_badges = badge(f'{r["broken_int_count"]} Broken', 'issue') if r['broken_int_count'] > 0 else badge('βœ“ None', 'ok')
bi_details = "".join(render_link_entry(l, domain) for l in r['broken_internal']) or '<div style="font-size:11px;color:var(--green);">No broken internal links.</div>'
be_badges = badge(f'{r["broken_ext_count"]} Broken', 'issue') if r['broken_ext_count'] > 0 else badge('βœ“ None', 'ok')
be_details = "".join(render_link_entry(l, domain) for l in r['broken_external']) or '<div style="font-size:11px;color:var(--green);">No broken external links.</div>'
ri_badges = badge(f'{r["redirect_int_count"]} Redirects', 'issue') if r['redirect_int_count'] > 0 else badge('βœ“ None', 'ok')
ri_details = "".join(render_link_entry(l, domain) for l in r['redirect_internal']) or '<div style="font-size:11px;color:var(--green);">No internal redirects.</div>'
re_badges = badge(f'{r["redirect_ext_count"]} Redirects', 'issue') if r['redirect_ext_count'] > 0 else badge('βœ“ None', 'ok')
re_details = "".join(render_link_entry(l, domain) for l in r['redirect_external']) or '<div style="font-size:11px;color:var(--green);">No external redirects.</div>'
# Duplicates
dup_badges = badge(f'{r["duplicate_count"]} Duplicates', 'issue') if r['duplicate_count'] > 0 else badge('βœ“ None', 'ok')
dup_details = ""
for d in r['duplicates']:
locs = ", ".join(esc(l) for l in d['locations'])
dup_details += f'<div class="le s-issue"><div class="le-url">{esc(short_url(d["url"], domain))}</div>'
dup_details += f'<div class="le-issue">⚠ Appears {d["count"]}x in body content</div>'
dup_details += f'<div class="le-location">πŸ“ Locations: {locs}</div></div>'
if not dup_details:
dup_details = '<div style="font-size:11px;color:var(--green);">No duplicate links in body content.</div>'
# Suggestions
sug_list = r['suggestions']
high_count = sum(1 for s in sug_list if s['priority'] == 'High')
sug_badges = ""
if sug_list:
sug_badges = badge(f'{len(sug_list)} Suggestions', 'sug')
if high_count: sug_badges += badge(f'{high_count} High', 'issue')
else:
sug_badges = badge('0', 'neutral')
sug_details = ""
for s in sug_list:
pri_cls = 'high' if s['priority'] == 'High' else 'med'
sug_details += f'''<div class="se"><div class="se-head"><span class="se-section">{esc(s["section"])}</span>
<span class="se-pri {pri_cls}">{s["priority"]}</span></div>
<div class="se-url">{esc(s["target"])}</div>
<div class="se-anchor">β†’ "{esc(s["anchor"])}"</div></div>'''
if not sug_details:
sug_details = '<div style="font-size:11px;color:var(--text-dim);">No keyword matches for suggestions.</div>'
# Notes
issues = []
if r['int_count'] < 3:
issues.append(f"Only {r['int_count']} internal links β€” very low for article length")
if r['broken_int_count'] + r['broken_ext_count'] > 0:
issues.append(f"{r['broken_int_count']+r['broken_ext_count']} broken link(s) need fixing")
if ext_df_flags_list:
issues.append(f"{len(ext_df_flags_list)} external links are Dofollow β€” add nofollow")
if int_nf_flags:
issues.append(f"{len(int_nf_flags)} internal links are Nofollow β€” should be Dofollow")
if r['redirect_int_count'] + r['redirect_ext_count'] > 0:
issues.append(f"{r['redirect_int_count']+r['redirect_ext_count']} redirect(s) β€” update href")
if r['duplicate_count'] > 0:
issues.append(f"{r['duplicate_count']} duplicate link(s) in body")
if is_orphan:
issues.append("ORPHAN PAGE β€” no incoming internal links from other pages")
note_badges = badge(f'{len(issues)} Issues', 'issue') if issues else badge('βœ“ Clean', 'ok')
note_details = "".join(f'<div class="ni critical">⚠ {esc(issue)}</div>' for issue in issues)
if not note_details:
note_details = '<div style="font-size:11px;color:var(--green);">No issues detected.</div>'
orphan_cell = '<span class="badge issue">Yes ⚠</span>' if is_orphan else '<span class="badge ok">No</span>'
rows_html += f'''<tr data-broken="{r['broken_int_count']+r['broken_ext_count']}" data-redirect="{r['redirect_int_count']+r['redirect_ext_count']}" data-internal="{r['int_count']}" data-follow-flag="{r['follow_flag_count']}" data-duplicates="{r['duplicate_count']}" data-orphan="{1 if is_orphan else 0}">
<td class="cell-url"><div class="row-num">#{idx}</div>{esc(short_url(r['url'], domain))}</td>
<td class="cell-count {'issue' if r['int_count']<3 else 'neutral'}">{r['int_count']}</td>
<td class="cell-count neutral">{r['ext_count']}</td>
{render_accordion(int_badges, int_details)}
{render_accordion(ext_badges, ext_details)}
<td class="cell-count"><span style="color:var(--green)">{r['int_df']}</span><span style="color:var(--text-dim)"> / </span><span style="color:{'var(--red)' if r['int_nf']>0 else 'var(--text-dim)'}">{r['int_nf']}</span></td>
<td class="cell-count"><span style="color:{'var(--red)' if r['ext_df']>0 else 'var(--text-dim)'}">{r['ext_df']}</span><span style="color:var(--text-dim)"> / </span><span style="color:var(--green)">{r['ext_nf']}</span></td>
{render_accordion(flag_badges, flag_details)}
{render_accordion(bi_badges, bi_details)}
{render_accordion(be_badges, be_details)}
{render_accordion(ri_badges, ri_details)}
{render_accordion(re_badges, re_details)}
{render_accordion(dup_badges, dup_details)}
<td class="cell-orphan">{orphan_cell}</td>
{render_accordion(sug_badges, sug_details)}
{render_accordion(note_badges, note_details)}
</tr>'''
report = f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Bulk Link Audit Report</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {{--bg:#f4f6f9;--surface:#fff;--surface-2:#f8f9fb;--surface-3:#eef1f5;--border:#e2e6ed;--border-light:#d0d5de;--text:#1a1f2e;--text-muted:#5c6478;--text-dim:#8892a6;--accent:#2563eb;--accent-dim:rgba(37,99,235,0.06);--accent-light:rgba(37,99,235,0.10);--green:#059669;--green-dim:rgba(5,150,105,0.06);--green-light:rgba(5,150,105,0.10);--red:#dc2626;--red-dim:rgba(220,38,38,0.05);--red-light:rgba(220,38,38,0.08);--orange:#d97706;--orange-dim:rgba(217,119,6,0.06);--purple:#7c3aed;--purple-dim:rgba(124,58,237,0.06);--pink:#db2777;--pink-dim:rgba(219,39,119,0.06);--mono:'JetBrains Mono',monospace;--font:'DM Sans',sans-serif;--radius:8px;--radius-sm:5px;}}
*{{margin:0;padding:0;box-sizing:border-box;}}body{{background:var(--bg);color:var(--text);font-family:var(--font);-webkit-font-smoothing:antialiased;line-height:1.55;}}
.header{{padding:32px 36px 24px;border-bottom:1px solid var(--border);background:var(--surface);}}.header .tag-line{{font-size:10px;font-weight:700;letter-spacing:1.8px;text-transform:uppercase;color:var(--accent);margin-bottom:8px;}}.header h1{{font-size:21px;font-weight:700;margin-bottom:4px;}}.header .meta{{font-size:11.5px;color:var(--text-dim);}}.header .meta span{{color:var(--text-muted);}}
.toolbar{{display:flex;align-items:center;gap:10px;padding:12px 36px;background:var(--surface);border-bottom:1px solid var(--border);flex-wrap:wrap;}}.toolbar-btn{{font-family:var(--font);font-size:11px;font-weight:600;color:var(--text-muted);background:var(--surface-2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:6px 14px;cursor:pointer;transition:all .15s;}}.toolbar-btn:hover{{color:var(--text);background:var(--surface-3);}}.toolbar-btn.active{{color:var(--accent);border-color:var(--accent);background:var(--accent-dim);}}.toolbar-sep{{width:1px;height:20px;background:var(--border);}}.toolbar-label{{font-size:10px;font-weight:600;letter-spacing:.5px;text-transform:uppercase;color:var(--text-dim);margin-right:4px;}}
.legend{{display:flex;flex-wrap:wrap;gap:18px;padding:10px 36px;background:var(--surface-2);border-bottom:1px solid var(--border);}}.legend-item{{display:flex;align-items:center;gap:6px;font-size:10.5px;color:var(--text-muted);}}.legend-dot{{width:8px;height:8px;border-radius:50%;}}.legend-dot.green{{background:var(--green);}}.legend-dot.red{{background:var(--red);}}.legend-dot.blue{{background:var(--accent);}}
.summary-bar{{display:flex;border-bottom:1px solid var(--border);background:var(--surface);flex-wrap:wrap;}}.summary-stat{{flex:1;padding:14px 16px;border-right:1px solid var(--border);text-align:center;min-width:90px;}}.summary-stat:last-child{{border-right:none;}}.summary-stat .s-val{{font-size:22px;font-weight:700;line-height:1;margin-bottom:2px;}}.summary-stat .s-label{{font-size:9px;font-weight:600;letter-spacing:.5px;text-transform:uppercase;color:var(--text-dim);}}.s-val.blue{{color:var(--accent);}}.s-val.green{{color:var(--green);}}.s-val.red{{color:var(--red);}}.s-val.pink{{color:var(--pink);}}
.table-wrap{{overflow-x:auto;background:var(--surface);}}table{{width:100%;border-collapse:collapse;min-width:2400px;}}
thead th{{background:var(--surface-3);color:var(--text-muted);font-size:9px;font-weight:700;letter-spacing:.7px;text-transform:uppercase;padding:12px 12px;text-align:left;border-bottom:2px solid var(--border-light);position:sticky;top:0;z-index:10;white-space:nowrap;}}thead th.center{{text-align:center;}}thead th.c-blue{{border-bottom-color:var(--accent);color:var(--accent);}}thead th.c-red{{border-bottom-color:var(--red);color:var(--red);}}thead th.c-green{{border-bottom-color:var(--green);color:var(--green);}}thead th.c-pink{{border-bottom-color:var(--pink);color:var(--pink);}}thead th:first-child{{position:sticky;left:0;z-index:15;border-right:2px solid var(--border-light);background:var(--surface-3);}}
tbody tr{{border-bottom:1px solid var(--border);transition:background .12s;}}tbody tr:hover{{background:var(--accent-dim);}}
tbody td{{padding:10px 12px;vertical-align:top;font-size:12px;border-right:1px solid var(--border);}}tbody td:last-child{{border-right:none;}}
td.cell-url{{font-family:var(--mono);font-size:11px;color:var(--accent);word-break:break-all;line-height:1.5;background:var(--surface);position:sticky;left:0;z-index:5;border-right:2px solid var(--border-light);min-width:260px;max-width:260px;}}td.cell-url .row-num{{font-size:9px;color:var(--text-dim);margin-bottom:4px;font-weight:600;}}
td.cell-count{{text-align:center;font-family:var(--mono);font-size:15px;font-weight:700;vertical-align:middle;}}td.cell-count.ok{{color:var(--green);}}td.cell-count.issue{{color:var(--red);}}td.cell-count.neutral{{color:var(--text);}}
.acc-cell{{cursor:pointer;user-select:none;}}.acc-collapsed{{display:flex;align-items:center;gap:8px;min-height:26px;}}.acc-chevron{{width:20px;height:20px;border-radius:4px;background:var(--surface-3);display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:transform .2s,background .15s;font-size:10px;color:var(--text-dim);border:1px solid var(--border);}}.acc-cell.open .acc-chevron{{transform:rotate(90deg);background:var(--accent-dim);color:var(--accent);border-color:var(--accent);}}.acc-summary{{font-size:11px;color:var(--text-muted);display:flex;flex-wrap:wrap;gap:4px;align-items:center;}}
.badge{{font-size:9px;font-weight:700;padding:2.5px 8px;border-radius:10px;letter-spacing:.2px;white-space:nowrap;border:1px solid transparent;}}.badge.ok{{background:var(--green-dim);color:var(--green);border-color:var(--green-light);}}.badge.issue{{background:var(--red-dim);color:var(--red);border-color:var(--red-light);}}.badge.sug{{background:var(--green-dim);color:var(--green);border-color:var(--green-light);}}.badge.neutral{{background:var(--surface-3);color:var(--text-dim);border-color:var(--border);}}
.acc-details{{display:none;margin-top:10px;padding-top:10px;border-top:1px dashed var(--border);}}.acc-cell.open .acc-details{{display:block;}}
.le{{padding:9px 11px;background:var(--surface-2);border-radius:var(--radius-sm);margin-bottom:6px;border:1px solid var(--border);border-left-width:3px;}}.le:last-child{{margin-bottom:0;}}.le.s-ok{{border-left-color:var(--green);}}.le.s-issue{{border-left-color:var(--red);background:var(--red-dim);}}
.le .le-url{{font-family:var(--mono);font-size:10.5px;color:var(--accent);word-break:break-all;margin-bottom:4px;}}.le.s-issue .le-url{{color:var(--red);}}
.le .le-tags{{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:4px;}}.tag-sm{{font-size:8.5px;font-weight:700;padding:2px 6px;border-radius:8px;}}.tag-sm.ok{{background:var(--green-light);color:var(--green);}}.tag-sm.issue{{background:var(--red-light);color:var(--red);}}
.le .le-anchor{{font-size:10.5px;color:var(--text-muted);}}.le .le-anchor b{{color:var(--text);font-weight:600;}}.le .le-redir{{font-family:var(--mono);font-size:10px;color:var(--orange);margin-top:3px;}}.le .le-location{{font-size:10px;color:var(--text-dim);margin-top:4px;}}.le .le-issue{{font-size:10px;color:var(--red);margin-top:5px;padding-top:5px;border-top:1px dashed var(--border);font-weight:500;}}.le .le-fix{{font-size:10px;color:var(--green);margin-top:3px;font-weight:500;}}
.se{{padding:8px 11px;background:var(--surface-2);border-radius:var(--radius-sm);margin-bottom:5px;border:1px solid var(--border);border-left:3px solid var(--green);}}.se:last-child{{margin-bottom:0;}}.se .se-head{{display:flex;justify-content:space-between;align-items:center;margin-bottom:2px;}}.se .se-section{{font-size:9.5px;font-weight:600;letter-spacing:.3px;text-transform:uppercase;color:var(--text-dim);}}.se-pri{{font-size:8.5px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;padding:2px 7px;border-radius:8px;}}.se-pri.high{{background:var(--red-light);color:var(--red);}}.se-pri.med{{background:rgba(217,119,6,.1);color:var(--orange);}}.se .se-url{{font-family:var(--mono);font-size:10px;color:var(--accent);word-break:break-all;margin-bottom:1px;}}.se .se-anchor{{font-size:10px;color:var(--green);font-weight:500;}}
.ni{{font-size:11px;color:var(--text-muted);line-height:1.5;padding:5px 0;border-bottom:1px dashed var(--border);}}.ni:last-child{{border-bottom:none;}}.ni.critical{{color:var(--red);font-weight:500;}}
td.cell-orphan{{text-align:center;vertical-align:middle;}}
.footer{{padding:18px 36px;border-top:1px solid var(--border);font-size:11px;color:var(--text-dim);text-align:center;background:var(--surface);}}
</style>
</head>
<body>
<div class="header"><div class="tag-line">Bulk Link Audit Report</div><h1>Blog β€” Body Content Link Analysis</h1><div class="meta">Scope: Body content only Β· <span>{now}</span> Β· Pages: <span>{total_pages}</span> Β· Domain: <span>{esc(domain)}</span></div></div>
<div class="toolbar"><span class="toolbar-label">Actions:</span><button class="toolbar-btn" onclick="expandAll()">⊞ Expand All</button><button class="toolbar-btn" onclick="collapseAll()">⊟ Collapse All</button><div class="toolbar-sep"></div><span class="toolbar-label">Filter:</span><button class="toolbar-btn filter-btn active" onclick="filterRows('all',this)">All ({total_pages})</button><button class="toolbar-btn filter-btn" onclick="filterRows('broken',this)">Broken ({total_broken})</button><button class="toolbar-btn filter-btn" onclick="filterRows('redirect',this)">Redirects ({total_redirects})</button><button class="toolbar-btn filter-btn" onclick="filterRows('low-links',this)">Low Links</button><button class="toolbar-btn filter-btn" onclick="filterRows('follow-flag',this)">Follow Flags ({total_flags})</button><button class="toolbar-btn filter-btn" onclick="filterRows('duplicates',this)">Duplicates ({total_dups})</button><button class="toolbar-btn filter-btn" onclick="filterRows('orphan',this)">Orphans ({total_orphan})</button></div>
<div class="legend"><div class="legend-item"><div class="legend-dot green"></div> No Issues</div><div class="legend-item"><div class="legend-dot red"></div> Issue (Broken / Flag / Redirect / Duplicate)</div><div class="legend-item"><div class="legend-dot blue"></div> Info</div></div>
<div class="summary-bar"><div class="summary-stat"><div class="s-val blue">{total_pages}</div><div class="s-label">Pages</div></div><div class="summary-stat"><div class="s-val blue">{total_int}</div><div class="s-label">Internal</div></div><div class="summary-stat"><div class="s-val blue">{total_ext}</div><div class="s-label">External</div></div><div class="summary-stat"><div class="s-val red">{total_broken}</div><div class="s-label">Broken</div></div><div class="summary-stat"><div class="s-val red">{total_redirects}</div><div class="s-label">Redirects</div></div><div class="summary-stat"><div class="s-val red">{total_flags}</div><div class="s-label">Follow Flags</div></div><div class="summary-stat"><div class="s-val pink">{total_dups}</div><div class="s-label">Duplicates</div></div><div class="summary-stat"><div class="s-val green">{total_sug}</div><div class="s-label">Suggestions</div></div><div class="summary-stat"><div class="s-val red">{total_orphan}</div><div class="s-label">Orphans</div></div></div>
<div class="table-wrap"><table><thead><tr><th>URL</th><th class="center c-blue">Int.</th><th class="center c-blue">Ext.</th><th class="c-blue">Internal Links</th><th class="c-blue">External Links</th><th class="center">Int DF/NF</th><th class="center">Ext DF/NF</th><th class="c-red">Follow Flags</th><th class="c-red">Broken Int.</th><th class="c-red">Broken Ext.</th><th class="c-red">Redirect Int.</th><th class="c-red">Redirect Ext.</th><th class="c-pink">Duplicates</th><th class="center">Orphan</th><th class="c-green">Suggestions</th><th>Notes</th></tr></thead>
<tbody>{rows_html}</tbody></table></div>
<div class="footer">Bulk Link Audit Β· Body Content Scope Β· {now} Β· Click β–Ά to expand</div>
<script>
function toggleAcc(c){{c.classList.toggle('open');}}
function expandAll(){{document.querySelectorAll('.acc-cell').forEach(c=>c.classList.add('open'));}}
function collapseAll(){{document.querySelectorAll('.acc-cell').forEach(c=>c.classList.remove('open'));}}
function filterRows(t,b){{document.querySelectorAll('.filter-btn').forEach(x=>x.classList.remove('active'));b.classList.add('active');document.querySelectorAll('tbody tr').forEach(r=>{{let d=r.dataset,s=true;if(t==='broken')s=parseInt(d.broken||0)>0;else if(t==='redirect')s=parseInt(d.redirect||0)>0;else if(t==='low-links')s=parseInt(d.internal||0)<5;else if(t==='follow-flag')s=parseInt(d.followFlag||0)>0;else if(t==='duplicates')s=parseInt(d.duplicates||0)>0;else if(t==='orphan')s=parseInt(d.orphan||0)>0;r.style.display=s?'':'none';}});}}
</script>
</body></html>'''
return report