234 lines
6.7 KiB
Python
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"")
|
|
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
|