Initial commit: Thread Ripper containerized application
This commit is contained in:
commit
a648a59c0f
16 files changed
+2213
No files matched your search
@@ -0,0 +1,233 @@
|
||||
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
|
||||
Reference in new issue
Block a user