Files
thread-ripper/thread_ripper/stitcher.py
T

234 lines
6.7 KiB
Python

import re
from typing import List
from .models import PostSegment
def clean_segment_text(text: str) -> str:
"""Clean up whitespace and trailing artifacts from text."""
lines = [l.strip() for l in text.split("\n")]
# Remove empty lines while preserving deliberate paragraph breaks
cleaned_lines = []
prev_blank = False
for line in lines:
if line:
cleaned_lines.append(line)
prev_blank = False
elif not prev_blank:
cleaned_lines.append("")
prev_blank = True
return "\n".join(cleaned_lines).strip()
def build_markdown(author_name: str, author_handle: str, canonical_url: str, segments: List[PostSegment]) -> str:
"""Stitches segments into a single cohesive Markdown document."""
lines = []
title = segments[0].text.split("\n")[0] if segments and segments[0].text else "Threads Post"
if len(title) > 80:
title = title[:77] + "..."
lines.append(f"# {title}")
lines.append("")
lines.append(f"**Author:** [{author_name} (@{author_handle})](https://www.threads.net/@{author_handle}) ")
lines.append(f"**Original Post:** [{canonical_url}]({canonical_url}) ")
lines.append(f"**Thread Segments:** {len(segments)}")
lines.append("")
lines.append("---")
lines.append("")
for idx, seg in enumerate(segments):
cleaned_text = clean_segment_text(seg.text)
if not cleaned_text and not seg.images:
continue
if cleaned_text:
lines.append(cleaned_text)
lines.append("")
# Include media images if any
for img in seg.images:
alt = img.alt_text or f"Thread Image {idx + 1}"
lines.append(f"![{alt}]({img.url})")
lines.append("")
lines.append("---")
lines.append(f"*Ripped with Thread Ripper from [Threads]({canonical_url})*")
return "\n".join(lines).strip()
def build_plain_text(author_name: str, author_handle: str, canonical_url: str, segments: List[PostSegment]) -> str:
"""Stitches segments into clean plain text."""
output = []
output.append(f"{author_name} (@{author_handle})")
output.append(f"Source: {canonical_url}")
output.append("=" * 60)
output.append("")
for seg in segments:
cleaned_text = clean_segment_text(seg.text)
if cleaned_text:
output.append(cleaned_text)
output.append("")
output.append("=" * 60)
return "\n".join(output).strip()
def build_standalone_html(author_name: str, author_handle: str, author_avatar: str, canonical_url: str, segments: List[PostSegment]) -> str:
"""Generates an elegant, responsive standalone HTML file."""
title = segments[0].text.split("\n")[0] if segments and segments[0].text else f"Thread by @{author_handle}"
if len(title) > 80:
title = title[:77] + "..."
content_sections = []
for idx, seg in enumerate(segments):
cleaned_text = clean_segment_text(seg.text)
paragraphs = cleaned_text.split("\n")
section_html = "<div class='thread-segment'>"
# Render bullet points or paragraphs cleanly
in_list = False
for p in paragraphs:
p = p.strip()
if not p:
continue
if p.startswith("- ") or p.startswith("• "):
if not in_list:
section_html += "<ul>"
in_list = True
section_html += f"<li>{p[2:]}</li>"
else:
if in_list:
section_html += "</ul>"
in_list = False
section_html += f"<p>{p}</p>"
if in_list:
section_html += "</ul>"
# Attached images
for img in seg.images:
section_html += f"<div class='segment-image'><img src='{img.url}' alt='{img.alt_text or ''}' loading='lazy' /></div>"
section_html += "</div>"
content_sections.append(section_html)
body_content = "\n".join(content_sections)
avatar_tag = f"<img class='avatar' src='{author_avatar}' alt='{author_handle}' />" if author_avatar else ""
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title} - Thread Ripper</title>
<style>
:root {{
--bg: #0e1117;
--card: #161b22;
--text: #e6edf3;
--text-muted: #8b949e;
--border: #30363d;
--accent: #58a6ff;
}}
@media (prefers-color-scheme: light) {{
:root {{
--bg: #f6f8fa;
--card: #ffffff;
--text: #1f2328;
--text-muted: #656d76;
--border: #d0d7de;
--accent: #0969da;
}}
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.65;
margin: 0;
padding: 32px 16px;
display: flex;
justify-content: center;
}}
.article {{
max-width: 680px;
width: 100%;
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 40px;
box-shadow: 0 8px 30px rgba(0,0,0,0.12);
}}
.header {{
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 28px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border);
}}
.avatar {{
width: 54px;
height: 54px;
border-radius: 50%;
object-fit: cover;
}}
.author-info h2 {{
margin: 0;
font-size: 1.25rem;
}}
.author-info a {{
color: var(--accent);
text-decoration: none;
font-size: 0.95rem;
}}
.thread-segment {{
margin-bottom: 24px;
font-size: 1.1rem;
}}
.thread-segment p {{
margin: 0 0 16px 0;
}}
.thread-segment ul {{
margin: 0 0 16px 0;
padding-left: 24px;
}}
.thread-segment li {{
margin-bottom: 8px;
}}
.segment-image img {{
max-width: 100%;
border-radius: 12px;
margin-top: 12px;
}}
.footer {{
margin-top: 36px;
padding-top: 20px;
border-top: 1px solid var(--border);
font-size: 0.85rem;
color: var(--text-muted);
display: flex;
justify-content: space-between;
}}
.footer a {{
color: var(--accent);
text-decoration: none;
}}
</style>
</head>
<body>
<article class="article">
<div class="header">
{avatar_tag}
<div class="author-info">
<h2>{author_name}</h2>
<a href="{canonical_url}" target="_blank">@{author_handle} on Threads</a>
</div>
</div>
<div class="thread-body">
{body_content}
</div>
<div class="footer">
<span>Reconstructed as 1 single post ({len(segments)} segments)</span>
<a href="{canonical_url}" target="_blank">View Original Post ↗</a>
</div>
</article>
</body>
</html>"""
return html