Strip all graphics and images from final results (Markdown, HTML, text, and UI)
This commit is contained in:
1 parent
7609d70dd1
commit
8a91a05c2a
5 files changed
+48
-62
No files matched your search
@@ -39,7 +39,8 @@ class RippedThread(BaseModel):
|
||||
class RipRequest(BaseModel):
|
||||
url: str
|
||||
mode: str = "author_only" # 'author_only' or 'all_replies'
|
||||
max_scrolls: int = 6
|
||||
include_images: bool = False
|
||||
max_scrolls: int = 4
|
||||
|
||||
class HistorySummary(BaseModel):
|
||||
id: str
|
||||
|
||||
+15
-14
@@ -42,7 +42,7 @@ class ThreadScraper:
|
||||
await self._playwright.stop()
|
||||
self._playwright = None
|
||||
|
||||
async def rip(self, url: str, mode: str = "author_only", max_scrolls: int = 4) -> RippedThread:
|
||||
async def rip(self, url: str, mode: str = "author_only", include_images: bool = False, max_scrolls: int = 4) -> RippedThread:
|
||||
"""Scrapes and rips a Threads post and chained continuation posts with optimized speed."""
|
||||
browser = await self.get_browser()
|
||||
context: BrowserContext = await browser.new_context(
|
||||
@@ -58,7 +58,7 @@ class ThreadScraper:
|
||||
"**/*",
|
||||
lambda route: route.abort()
|
||||
if route.request.resource_type in ["image", "media", "font"]
|
||||
else route.continue_()
|
||||
else route.continue_(),
|
||||
)
|
||||
|
||||
# 2. Fast navigation using domcontentloaded instead of networkidle
|
||||
@@ -172,16 +172,17 @@ class ThreadScraper:
|
||||
continue
|
||||
seen_texts.add(text_hash)
|
||||
|
||||
# Extract post images
|
||||
imgs = await c.query_selector_all("img")
|
||||
# Extract post images (only real content photos, never profile avatars)
|
||||
post_images: List[MediaItem] = []
|
||||
for img in imgs:
|
||||
src = await img.get_attribute("src")
|
||||
alt = await img.get_attribute("alt") or ""
|
||||
if src:
|
||||
if "profile_pic" in src or ("scontent" in src and "206x206" in src):
|
||||
continue
|
||||
post_images.append(MediaItem(type="image", url=src, alt_text=alt))
|
||||
if include_images:
|
||||
imgs = await c.query_selector_all("img")
|
||||
for img in imgs:
|
||||
src = await img.get_attribute("src")
|
||||
alt = await img.get_attribute("alt") or ""
|
||||
if src:
|
||||
if "profile_pic" in src or "profile picture" in alt.lower() or ("scontent" in src and "206x206" in src):
|
||||
continue
|
||||
post_images.append(MediaItem(type="image", url=src, alt_text=alt))
|
||||
|
||||
raw_segments.append(
|
||||
PostSegment(
|
||||
@@ -211,10 +212,10 @@ class ThreadScraper:
|
||||
)
|
||||
)
|
||||
|
||||
# Build stitched outputs
|
||||
markdown_out = build_markdown(author_name, author_handle, canonical_url, raw_segments)
|
||||
# Build stitched outputs without graphics by default
|
||||
markdown_out = build_markdown(author_name, author_handle, canonical_url, raw_segments, include_images=include_images)
|
||||
plain_text_out = build_plain_text(author_name, author_handle, canonical_url, raw_segments)
|
||||
html_out = build_standalone_html(author_name, author_handle, author_avatar, canonical_url, raw_segments)
|
||||
html_out = build_standalone_html(author_name, author_handle, canonical_url, raw_segments, include_images=include_images)
|
||||
|
||||
thread_id = hashlib.md5(canonical_url.split("?")[0].encode("utf-8")).hexdigest()[:12]
|
||||
title = raw_segments[0].text.split("\n")[0] if raw_segments and raw_segments[0].text else f"Thread by @{author_handle}"
|
||||
|
||||
@@ -42,7 +42,12 @@ async def rip_endpoint(req: RipRequest):
|
||||
raise HTTPException(status_code=400, detail="Invalid Threads URL. Please provide a valid threads.net or threads.com link.")
|
||||
|
||||
try:
|
||||
ripped = await scraper.rip(url=req.url, mode=req.mode, max_scrolls=req.max_scrolls)
|
||||
ripped = await scraper.rip(
|
||||
url=req.url,
|
||||
mode=req.mode,
|
||||
include_images=req.include_images,
|
||||
max_scrolls=req.max_scrolls
|
||||
)
|
||||
storage.save(ripped)
|
||||
return ripped
|
||||
except Exception as e:
|
||||
|
||||
@@ -94,7 +94,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const response = await fetch("/api/rip", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url, mode, max_scrolls: 4 })
|
||||
body: JSON.stringify({ url, mode, include_images: false, max_scrolls: 4 })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -161,16 +161,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
function renderThread(thread) {
|
||||
resultSection.classList.remove("hidden");
|
||||
|
||||
// Header info
|
||||
// Header info (clean text header, no graphic avatar)
|
||||
resAuthorName.textContent = thread.author_name;
|
||||
resAuthorLink.textContent = `@${thread.author_handle}`;
|
||||
resAuthorLink.href = thread.canonical_url;
|
||||
if (thread.author_avatar) {
|
||||
resAvatar.src = thread.author_avatar;
|
||||
resAvatar.classList.remove("hidden");
|
||||
} else {
|
||||
resAvatar.classList.add("hidden");
|
||||
}
|
||||
resAvatar.classList.add("hidden");
|
||||
|
||||
resSegmentCount.textContent = thread.segment_count;
|
||||
resWordCount.textContent = thread.total_words;
|
||||
|
||||
+22
-38
@@ -5,7 +5,6 @@ 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:
|
||||
@@ -17,8 +16,8 @@ def clean_segment_text(text: str) -> str:
|
||||
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."""
|
||||
def build_markdown(author_name: str, author_handle: str, canonical_url: str, segments: List[PostSegment], include_images: bool = False) -> str:
|
||||
"""Stitches segments into a single cohesive Markdown document without graphics."""
|
||||
lines = []
|
||||
title = segments[0].text.split("\n")[0] if segments and segments[0].text else "Threads Post"
|
||||
if len(title) > 80:
|
||||
@@ -35,18 +34,17 @@ def build_markdown(author_name: str, author_handle: str, canonical_url: str, seg
|
||||
|
||||
for idx, seg in enumerate(segments):
|
||||
cleaned_text = clean_segment_text(seg.text)
|
||||
if not cleaned_text and not seg.images:
|
||||
if not cleaned_text:
|
||||
continue
|
||||
|
||||
if cleaned_text:
|
||||
lines.append(cleaned_text)
|
||||
lines.append("")
|
||||
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("")
|
||||
if include_images:
|
||||
for img in seg.images:
|
||||
alt = img.alt_text or f"Image {idx + 1}"
|
||||
lines.append(f"")
|
||||
lines.append("")
|
||||
|
||||
lines.append("---")
|
||||
lines.append(f"*Ripped with Thread Ripper from [Threads]({canonical_url})*")
|
||||
@@ -69,8 +67,8 @@ def build_plain_text(author_name: str, author_handle: str, canonical_url: str, s
|
||||
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."""
|
||||
def build_standalone_html(author_name: str, author_handle: str, canonical_url: str, segments: List[PostSegment], include_images: bool = False) -> str:
|
||||
"""Generates an elegant, text-focused standalone HTML document without clutter."""
|
||||
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] + "..."
|
||||
@@ -78,10 +76,11 @@ def build_standalone_html(author_name: str, author_handle: str, author_avatar: s
|
||||
content_sections = []
|
||||
for idx, seg in enumerate(segments):
|
||||
cleaned_text = clean_segment_text(seg.text)
|
||||
if not cleaned_text:
|
||||
continue
|
||||
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()
|
||||
@@ -100,15 +99,14 @@ def build_standalone_html(author_name: str, author_handle: str, author_avatar: s
|
||||
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>"
|
||||
if include_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">
|
||||
@@ -139,7 +137,7 @@ def build_standalone_html(author_name: str, author_handle: str, author_avatar: s
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.65;
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
padding: 32px 16px;
|
||||
display: flex;
|
||||
@@ -155,22 +153,14 @@ def build_standalone_html(author_name: str, author_handle: str, author_avatar: s
|
||||
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;
|
||||
margin: 0 0 6px 0;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
}}
|
||||
.author-info a {{
|
||||
color: var(--accent);
|
||||
@@ -178,7 +168,7 @@ def build_standalone_html(author_name: str, author_handle: str, author_avatar: s
|
||||
font-size: 0.95rem;
|
||||
}}
|
||||
.thread-segment {{
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.1rem;
|
||||
}}
|
||||
.thread-segment p {{
|
||||
@@ -191,11 +181,6 @@ def build_standalone_html(author_name: str, author_handle: str, author_avatar: s
|
||||
.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;
|
||||
@@ -214,7 +199,6 @@ def build_standalone_html(author_name: str, author_handle: str, author_avatar: s
|
||||
<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>
|
||||
|
||||
Reference in new issue
Block a user