Automatically backup Tieba posts in one single click
// ==UserScript==
// @name Tieba Post Backup Tool
// @namespace https://github.com/ZXPrism/TiebaPostBackupTool
// @version 2.1.0
// @description Automatically backup Tieba posts in one single click
// @author ZXPrism
// @license MIT
// @match https://tieba.baidu.com/p/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @connect gss0.bdstatic.com
// @connect gss1.bdstatic.com
// @connect gss2.bdstatic.com
// @connect gss3.bdstatic.com
// @connect gss4.bdstatic.com
// @connect gsp0.baidu.com
// @connect himg.bdimg.com
// @connect tb0.bdstatic.com
// @connect tb1.bdstatic.com
// @connect tb2.bdstatic.com
// @connect tiebapic.baidu.com
// @connect imgsa.baidu.com
// @connect static.tieba.baidu.com
// @connect tieba.baidu.com
// @run-at document-idle
// ==/UserScript==
"use strict";
(() => {
// src/image_handler.ts
var ImageHandler = class {
constructor() {
this.image_map = {};
this.image_counter = 1;
}
/**
* Extract all image URLs from post content and avatars.
* Returns mapped image URLs.
*/
process_images(post_html, avatar_urls) {
this.image_map = {};
this.image_counter = 1;
const img_tags = post_html.match(/<img[^>]+>/g) || [];
img_tags.forEach((img_tag) => {
const src_match = img_tag.match(/src=["']([^"']+)["']/);
if (src_match) {
this._add_to_image_map(src_match[1]);
}
});
avatar_urls.forEach((url) => {
if (url) {
this._add_to_image_map(url);
}
});
return this.image_map;
}
/**
* Add image URL to map with deduplication.
*/
_add_to_image_map(original_url) {
if (!this.image_map[original_url]) {
const ext = this._get_extension(original_url);
const new_name = `image_${this.image_counter++}.${ext}`;
this.image_map[original_url] = new_name;
}
}
/**
* Get file extension from URL.
*/
_get_extension(url) {
if (url.includes("static.tieba.baidu.com")) {
return "png";
}
const match = url.match(/\.([a-z]{3,4})(?:\?|$)/i);
return match ? match[1] : "jpg";
}
/**
* Replace image URLs in HTML content with new names.
*/
replace_image_urls(html_content) {
return html_content.replace(/src=["']([^"']+)["']/g, (match, url) => {
const new_name = this.image_map[url];
if (new_name) {
return `src="./images/${new_name}"`;
}
return match;
});
}
/**
* Download all images as blobs with batching to prevent IP ban.
* @param on_progress Callback for progress updates (current, total, filename)
*/
async download_images(on_progress) {
const blobs = {};
const entries = Object.entries(this.image_map);
const total = entries.length;
let current = 0;
const BATCH_SIZE = 5;
const BATCH_DELAY = 1e3;
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
const batch = entries.slice(i, Math.min(i + BATCH_SIZE, entries.length));
await Promise.all(batch.map(async ([original_url, new_name]) => {
try {
const blob = await this._fetch_image(original_url);
blobs[new_name] = blob;
current++;
if (on_progress) {
on_progress(current, total, new_name);
}
} catch {
blobs[new_name] = this._create_placeholder(new_name);
current++;
if (on_progress) {
on_progress(current, total, new_name);
}
}
}));
if (i + BATCH_SIZE < entries.length) {
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY));
}
}
return blobs;
}
/**
* Fetch single image as blob using GM_xmlhttpRequest to bypass CORS.
*/
async _fetch_image(url) {
return new Promise((resolve, reject) => {
if (typeof GM_xmlhttpRequest !== "undefined") {
GM_xmlhttpRequest({
method: "GET",
url,
responseType: "blob",
onload: (response) => {
if (response.status === 200) {
resolve(response.response);
} else {
reject(new Error(`HTTP ${response.status}`));
}
},
onerror: (_error) => {
reject(new Error("GM_xmlhttpRequest error"));
}
});
} else {
fetch(url).then((response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.blob();
}).then((blob) => resolve(blob)).catch((error) => reject(error));
}
});
}
/**
* Create placeholder image for failed downloads.
*/
_create_placeholder(_filename) {
const svg = `
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" fill="#ccc"/>
<text x="50" y="50" text-anchor="middle" dy=".3em" font-size="12">Failed</text>
</svg>
`;
return new Blob([svg], { type: "image/svg+xml" });
}
/**
* Replace avatar URLs in floor list with mapped filenames.
*/
replace_avatar_urls(floor_list) {
floor_list.forEach((floor) => {
if (floor.comment.avatar_url && this.image_map[floor.comment.avatar_url]) {
floor.comment.avatar_url = this.image_map[floor.comment.avatar_url];
}
if (floor.sub_comment_list) {
floor.sub_comment_list.forEach((sub) => {
if (sub.avatar_url && this.image_map[sub.avatar_url]) {
sub.avatar_url = this.image_map[sub.avatar_url];
}
});
}
});
}
/**
* Create viewer HTML file.
*/
create_viewer_html(post) {
const json_data = JSON.stringify(post, null, 2);
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${post.title} - Tieba Backup</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
.header {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.header h1 {
margin: 0 0 10px 0;
color: #333;
}
.header .meta {
color: #666;
font-size: 14px;
}
.floor {
background: white;
padding: 15px;
border-radius: 8px;
margin-bottom: 15px;
}
.floor .author {
font-weight: bold;
color: #0055cc;
}
.floor .date {
color: #999;
font-size: 12px;
margin-left: 10px;
}
.floor .ip {
color: #999;
font-size: 12px;
margin-left: 10px;
}
.floor .content {
margin: 10px 0;
line-height: 1.6;
}
.floor .content img {
max-width: 100%;
height: auto;
}
.floor .avatar {
width: 50px;
height: 50px;
border-radius: 4px;
margin-right: 10px;
vertical-align: middle;
object-fit: cover;
}
.floor .avatar[alt] {
display: inline-block;
}
.floor .floor-index {
color: #999;
font-size: 12px;
margin-bottom: 5px;
}
.floor .author-line {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.sub-comments {
margin-left: 20px;
padding-left: 15px;
border-left: 2px solid #e0e0e0;
margin-top: 10px;
}
.sub-comment {
margin: 8px 0;
padding: 8px;
background: #f9f9f9;
border-radius: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.sub-comment .content {
flex: 1;
}
.sub-comment .author {
font-size: 12px;
}
.sub-comment .date {
font-size: 11px;
}
.sub-comment .sub-avatar {
width: 30px;
height: 30px;
border-radius: 3px;
margin-right: 8px;
vertical-align: middle;
object-fit: cover;
}
.author-link {
color: #0055cc;
text-decoration: none;
font-weight: bold;
}
.author-link:hover {
text-decoration: underline;
}
.reply-to {
color: #666;
font-size: 12px;
}
.reply-to a {
color: #0055cc;
text-decoration: none;
}
.reply-to a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="header">
<h1>${post.title}</h1>
<div class="meta">
\u8D34\u5427: <a class="author-link" href="${post.tieba_url}" target="_blank">${post.tieba_name}</a> |
\u697C\u5C42: ${post.floor_list.length} |
\u56DE\u590D: ${post.comment_cnt}
</div>
</div>
<div id="content"></div>
<script>
try {
const post = ${json_data};
function renderFloor(floor) {
let html = '<div class="floor">';
// Floor index
html += '<div class="floor-index">' + floor.index + '\u697C</div>';
// Author with avatar
html += '<div class="author-line">';
if (floor.comment.avatar_url) {
html += '<img class="avatar" src="./images/' + floor.comment.avatar_url + '" alt="">';
}
// Author as link if homepage exists
if (floor.comment.homepage_url) {
html += '<a class="author-link" href="' + floor.comment.homepage_url + '" target="_blank">' + floor.comment.author + '</a>';
} else {
html += '<span class="author">' + floor.comment.author + '</span>';
}
html += '<span class="date">' + floor.comment.date + '</span>';
if (floor.comment.ip_location) {
html += '<span class="ip">\u6765\u81EA: ' + floor.comment.ip_location + '</span>';
}
html += '</div>';
// Content
html += '<div class="content">' + floor.comment.content + '</div>';
// Sub-comments
if (floor.sub_comment_list && floor.sub_comment_list.length > 0) {
html += '<div class="sub-comments">';
floor.sub_comment_list.forEach(sub => {
html += renderSubComment(sub);
});
html += '</div>';
}
html += '</div>';
return html;
}
function renderSubComment(comment) {
let html = '<div class="sub-comment">';
// Avatar
if (comment.avatar_url) {
html += '<img class="sub-avatar" src="./images/' + comment.avatar_url + '" alt="">';
}
// Debug logging
if (!comment.author || comment.author === 'Unknown') {
}
// Author with fallback for missing names, as link if homepage exists
const author_name = comment.author || '\u672A\u77E5\u7528\u6237';
if (comment.homepage_url) {
html += '<a class="author-link" href="' + comment.homepage_url + '" target="_blank">' + author_name + '</a>';
} else {
html += '<span class="author">' + author_name + '</span>';
}
html += '<span class="date">' + comment.date + '</span>';
// Content with reply-to filtering
let content = comment.content;
// Remove the original "\u56DE\u590D <a>username</a>" pattern since we'll render it separately
// Use string methods instead of regex to avoid escaping issues
const replyIndex = content.indexOf('\u56DE\u590D');
if (replyIndex !== -1) {
// Find the closing </a> and the colon after it
const anchorEnd = content.indexOf('</a>', replyIndex);
if (anchorEnd !== -1) {
const colonIndex = content.indexOf(':', anchorEnd);
if (colonIndex !== -1 && colonIndex < anchorEnd + 10) {
// Remove everything from "\u56DE\u590D" to the colon
content = content.substring(0, replyIndex) + content.substring(colonIndex + 1);
}
}
}
html += '<div class="content">' + content + '</div>';
// Reply-to information
if (comment.reply_to_author) {
html += '<div class="reply-to">\u56DE\u590D ';
if (comment.reply_to_homepage_url) {
html += '<a href="' + comment.reply_to_homepage_url + '" target="_blank">' + comment.reply_to_author + '</a>';
} else {
html += comment.reply_to_author;
}
html += '</div>';
}
html += '</div>';
return html;
}
// Render all floors
const contentDiv = document.getElementById('content');
if (!contentDiv) {
} else {
post.floor_list.forEach((floor, index) => {
try {
contentDiv.innerHTML += renderFloor(floor);
} catch (e) {
}
});
}
} catch (error) {
document.getElementById('content').innerHTML = '<p style="color: red; padding: 20px;">Error rendering post: ' + error.message + '</p>';
}
</script>
</body>
</html>`;
}
/**
* Create tar archive from files.
*/
async create_tar(files) {
const chunks = [];
const encoder = new TextEncoder();
for (const [filename, content] of Object.entries(files)) {
let file_data;
let file_size;
if (typeof content === "string") {
file_data = encoder.encode(content);
file_size = file_data.length;
} else {
file_data = await this._blob_to_uint8array(content);
file_size = file_data.length;
}
const header = this._create_tar_header(filename, file_size);
chunks.push(header);
chunks.push(file_data);
const padding_needed = (512 - file_size % 512) % 512;
if (padding_needed > 0) {
chunks.push(new Uint8Array(padding_needed));
}
}
chunks.push(new Uint8Array(1024));
const total_size = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const combined = new Uint8Array(total_size);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
return new Blob([combined], { type: "application/x-tar" });
}
/**
* Convert blob to Uint8Array.
*/
async _blob_to_uint8array(blob) {
const buffer = await blob.arrayBuffer();
return new Uint8Array(buffer);
}
/**
* Create tar header for a file.
*/
_create_tar_header(filename, file_size) {
const header = new Uint8Array(512);
const encoder = new TextEncoder();
const name_bytes = encoder.encode(filename);
header.set(name_bytes.subarray(0, Math.min(100, name_bytes.length)), 0);
encoder.encode("0000644 ").forEach((byte, i) => header[100 + i] = byte);
encoder.encode("0000000 ").forEach((byte, i) => header[108 + i] = byte);
encoder.encode("0000000 ").forEach((byte, i) => header[116 + i] = byte);
const size_octal = file_size.toString(8).padStart(11, "0") + "\0";
encoder.encode(size_octal).forEach((byte, i) => header[124 + i] = byte);
const timestamp = Math.floor(Date.now() / 1e3).toString(8).padStart(11, "0") + "\0";
encoder.encode(timestamp).forEach((byte, i) => header[136 + i] = byte);
header[156] = 48;
encoder.encode(" ").forEach((byte, i) => header[148 + i] = byte);
let checksum = 0;
for (let i = 0; i < 512; i++) {
checksum += header[i];
}
const checksum_str = checksum.toString(8).padStart(6, "0") + "\0 ";
encoder.encode(checksum_str).forEach((byte, i) => header[148 + i] = byte);
return header;
}
/**
* Download blob as file.
*/
download_blob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
};
// src/parser.ts
var Parser = class {
constructor() {
this.image_handler = new ImageHandler();
this.base_url = window.location.href.split("?")[0];
this.current_page = this._get_current_page_number();
}
/**
* Get current page number from URL query parameter.
*/
_get_current_page_number() {
const url_params = new URLSearchParams(window.location.search);
const pn = url_params.get("pn");
return pn ? parseInt(pn) : 1;
}
/**
* Check if reset mode is active (don't auto-save state).
*/
is_reset_mode() {
return GM_getValue("reset_mode", false);
}
/**
* Reset parsing state (clear all stored data).
*/
reset_parsing() {
GM_deleteValue("parse_state");
}
/**
* Save parsing state to GM storage (respects reset mode).
*/
_save_state(state) {
if (!this.is_reset_mode()) {
GM_setValue("parse_state", state);
}
}
/**
* Load parsing state from GM storage.
*/
_load_state() {
const state = GM_getValue("parse_state", void 0);
return state || null;
}
/**
* Clear parsing state from GM storage.
*/
_clear_state() {
GM_deleteValue("parse_state");
}
/**
* Check if state is stale (older than 30 minutes).
*/
_is_state_stale(state) {
const THIRTY_MINUTES = 30 * 60 * 1e3;
return Date.now() - state.timestamp > THIRTY_MINUTES;
}
/**
* Check if parsing is in progress for this post.
*/
is_parsing_in_progress() {
const state = this._load_state();
if (!state || !state.active) {
return false;
}
if (this._is_state_stale(state)) {
this._clear_state();
return false;
}
if (state.post_id !== this._parse_post_id()) {
return false;
}
return true;
}
/**
* Cancel ongoing parsing (user manually aborted).
*/
cancel_parsing() {
const state = this._load_state();
if (state && state.active) {
this._clear_state();
}
}
/**
* Get the image handler instance (for accessing image_map and downloading).
*/
get_image_handler() {
return this.image_handler;
}
/**
* Parse the complete post from current page DOM.
* Automatically loads all content before parsing.
* Handles multi-page posts with state persistence.
*/
async parse_post() {
const post_id = this._parse_post_id();
const existing_state = this._load_state();
if (existing_state && existing_state.active && existing_state.post_id === post_id) {
if (this._is_state_stale(existing_state)) {
this._clear_state();
} else {
return await this._resume_parse(existing_state);
}
}
return await this._start_parse();
}
/**
* Start a new parsing session.
*/
async _start_parse() {
const post_id = this._parse_post_id();
const page_cnt = this._parse_page_count();
if (this.current_page !== 1) {
const state2 = {
active: true,
post_id,
current_page: 1,
total_pages: page_cnt,
collected_floors: [],
timestamp: Date.now()
};
this._save_state(state2);
this._navigate_to_page(1);
throw new Error("NAVIGATING");
}
const state = {
active: true,
post_id,
current_page: this.current_page,
total_pages: page_cnt,
collected_floors: [],
timestamp: Date.now()
};
this._save_state(state);
const page_floors = await this._parse_current_page();
state.collected_floors = state.collected_floors.concat(page_floors);
state.current_page = this.current_page + 1;
this._save_state(state);
if (state.current_page <= state.total_pages) {
this._navigate_to_page(state.current_page);
throw new Error("NAVIGATING");
} else {
return await this._finalize_parse(state);
}
}
/**
* Resume an existing parsing session.
*/
async _resume_parse(state) {
const page_floors = await this._parse_current_page();
state.collected_floors = state.collected_floors.concat(page_floors);
state.current_page = this.current_page + 1;
this._save_state(state);
if (state.current_page <= state.total_pages) {
this._navigate_to_page(state.current_page);
throw new Error("NAVIGATING");
} else {
return await this._finalize_parse(state);
}
}
/**
* Finalize parsing and create the complete post.
*/
async _finalize_parse(state) {
const title = this._parse_title();
const tieba_name = this._parse_tieba_name();
const tieba_url = this._parse_tieba_url();
const post_id = state.post_id;
const page_cnt = state.total_pages;
const comment_cnt = this._parse_comment_count();
const avatar_urls = this._extract_all_avatar_urls(state.collected_floors);
const all_html = this._collect_all_html(state.collected_floors);
const image_map = this.image_handler.process_images(all_html, avatar_urls);
this._replace_image_urls_in_floors(state.collected_floors);
this.image_handler.replace_avatar_urls(state.collected_floors);
const post = {
title,
tieba_name,
tieba_url,
id: post_id,
page_cnt,
comment_cnt,
floor_list: state.collected_floors,
image_map
};
this._clear_state();
return post;
}
/**
* Navigate to a specific page.
*/
_navigate_to_page(page_number) {
const page_url = `${this.base_url}?pn=${page_number}`;
window.location.href = page_url;
}
/**
* Parse the current page with full loading process.
*/
async _parse_current_page() {
const original_zoom = this._save_original_zoom();
this._set_zoom(0.3);
await new Promise((resolve) => setTimeout(resolve, 500));
await this._scroll_to_load_floors();
await this._expand_all_sub_comments();
const floor_list = await this._parse_floors();
this._set_zoom(original_zoom);
return floor_list;
}
/**
* Extract all avatar URLs from floor list.
*/
_extract_all_avatar_urls(floor_list) {
const avatar_urls = [];
floor_list.forEach((floor) => {
if (floor.comment.avatar_url) {
avatar_urls.push(floor.comment.avatar_url);
}
if (floor.sub_comment_list) {
floor.sub_comment_list.forEach((sub) => {
if (sub.avatar_url) {
avatar_urls.push(sub.avatar_url);
}
});
}
});
return avatar_urls;
}
/**
* Collect all HTML content from floor list.
*/
_collect_all_html(floor_list) {
let all_html = "";
floor_list.forEach((floor) => {
all_html += floor.comment.content;
if (floor.sub_comment_list) {
floor.sub_comment_list.forEach((sub) => {
all_html += sub.content;
});
}
});
return all_html;
}
/**
* Replace image URLs in all floor content.
*/
_replace_image_urls_in_floors(floor_list) {
floor_list.forEach((floor) => {
floor.comment.content = this.image_handler.replace_image_urls(floor.comment.content);
if (floor.sub_comment_list) {
floor.sub_comment_list.forEach((sub) => {
sub.content = this.image_handler.replace_image_urls(sub.content);
});
}
});
}
/**
* Save original zoom level.
*/
_save_original_zoom() {
const current_zoom = document.body.style.zoom || "1";
return parseFloat(current_zoom);
}
/**
* Set zoom level.
*/
_set_zoom(level) {
document.body.style.zoom = level.toString();
}
/**
* Stage 1: Scroll to load all lazy-loaded floors.
*/
async _scroll_to_load_floors() {
return new Promise((resolve) => {
window.scrollTo(0, 0);
const scroll_task = setInterval(() => {
window.scrollBy(0, 100);
const max_scroll = document.documentElement.scrollHeight - window.innerHeight;
if (window.scrollY + window.innerHeight >= max_scroll - 50) {
setTimeout(() => {
clearInterval(scroll_task);
window.scrollTo(0, document.documentElement.scrollHeight);
setTimeout(() => {
resolve();
}, 1e3);
}, 500);
}
}, 100);
});
}
/**
* Stage 2: Expand all sub-comment sections.
*/
async _expand_all_sub_comments() {
const expand_buttons = document.querySelectorAll(".j_lzl_m");
expand_buttons.forEach((button) => {
try {
button.click();
} catch {
}
});
await this._wait_for_dom_update();
}
/**
* Parse post title from DOM.
*/
_parse_title() {
const title_element = document.querySelector(".core_title_txt");
if (!title_element) {
throw new Error("Post title not found");
}
return title_element.textContent?.trim() || "";
}
/**
* Parse tieba name from DOM.
*/
_parse_tieba_name() {
const tieba_element = document.querySelector(".card_title_fname");
if (!tieba_element) {
throw new Error("Tieba name not found");
}
return tieba_element.textContent?.trim() || "";
}
/**
* Parse tieba URL from DOM.
*/
_parse_tieba_url() {
const tieba_link = document.querySelector(".card_title_fname");
if (tieba_link && tieba_link.tagName === "A") {
const href = tieba_link.getAttribute("href");
if (href) {
if (href.startsWith("/")) {
return "https://tieba.baidu.com" + href;
}
return href;
}
}
const tieba_name = this._parse_tieba_name();
return `https://tieba.baidu.com/f?kw=${encodeURIComponent(tieba_name)}&fr=index`;
}
/**
* Parse post ID from URL.
* URL format: tieba.baidu.com/p/{id}
*/
_parse_post_id() {
const url_match = window.location.href.match(/\/p\/(\d+)/);
if (!url_match) {
throw new Error("Post ID not found in URL");
}
return parseInt(url_match[1]);
}
/**
* Parse total page count from DOM.
*/
_parse_page_count() {
const posts_num_element = document.querySelector(".l_posts_num");
if (!posts_num_element) {
throw new Error("Page count not found");
}
const text = posts_num_element.textContent || "";
const page_match = text.match(/共(\d+)页/);
if (!page_match) {
throw new Error("Page count format not recognized");
}
return parseInt(page_match[1]);
}
/**
* Parse total comment count from DOM.
*/
_parse_comment_count() {
const posts_num_element = document.querySelector(".l_posts_num");
if (!posts_num_element) {
throw new Error("Comment count not found");
}
const text = posts_num_element.textContent || "";
const comment_match = text.match(/(\d+)回复贴/);
if (!comment_match) {
throw new Error("Comment count format not recognized");
}
return parseInt(comment_match[1]);
}
/**
* Parse all floors from DOM.
*/
async _parse_floors() {
const floors = [];
const floor_elements = Array.from(document.querySelectorAll(".l_post.l_post_bright"));
for (let i = 0; i < floor_elements.length; i++) {
const floor_element = floor_elements[i];
const data_field = floor_element.getAttribute("data-field");
if (data_field === "{}") {
continue;
}
try {
const floor = await this._parse_floor(floor_element);
floors.push(floor);
} catch {
}
}
return floors;
}
/**
* Parse single floor from DOM element.
*/
async _parse_floor(floor_element) {
const comment = this._parse_main_comment(floor_element);
const index = this._parse_floor_index(floor_element);
const sub_comments = await this._parse_sub_comments(floor_element);
return {
comment,
index,
sub_comment_list: sub_comments.length > 0 ? sub_comments : void 0
};
}
/**
* Parse main comment from floor element.
*/
_parse_main_comment(floor_element) {
const author = this._parse_author_name(floor_element);
const date = this._parse_date(floor_element);
const content = this._parse_content(floor_element);
const ip_location = this._parse_ip_location(floor_element);
const avatar_url = this._parse_avatar_url(floor_element);
const homepage_url = this._parse_homepage_url(floor_element);
return {
author,
date,
content,
ip_location,
avatar_url,
homepage_url
};
}
/**
* Parse author name from floor element.
*/
_parse_author_name(floor_element) {
const author_element = floor_element.querySelector(".p_author_name");
if (!author_element) {
throw new Error("Author name not found");
}
return author_element.textContent?.trim() || "";
}
/**
* Parse homepage URL from floor element.
*/
_parse_homepage_url(floor_element) {
const author_link = floor_element.querySelector(".p_author_name");
if (author_link && author_link.tagName === "A") {
const href = author_link.getAttribute("href");
if (href) {
if (href.startsWith("/")) {
return "https://tieba.baidu.com" + href;
}
return href;
}
}
const home_link = floor_element.querySelector('a[href*="/home/main"]');
if (home_link) {
const href = home_link.getAttribute("href");
if (href) {
if (href.startsWith("/")) {
return "https://tieba.baidu.com" + href;
}
return href;
}
}
return void 0;
}
/**
* Parse avatar URL from floor element.
*/
_parse_avatar_url(floor_element) {
const avatar_img = floor_element.querySelector(".p_author_face img");
if (avatar_img) {
return avatar_img.getAttribute("src") || void 0;
}
const fallback_avatar = floor_element.querySelector('img[src*="static.tieba.baidu.com"]');
if (fallback_avatar) {
return fallback_avatar.getAttribute("src") || void 0;
}
return void 0;
}
/**
* Parse date from floor element.
* Handles both old and new Tieba DOM formats.
*/
_parse_date(floor_element) {
const new_format_tail = floor_element.querySelector(".core_reply_tail");
if (new_format_tail) {
const text = new_format_tail.textContent || "";
const date_match = text.match(/(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})/);
if (date_match) {
return date_match[1];
}
}
const old_format_date = floor_element.querySelector(".tail-info");
if (old_format_date) {
const text = old_format_date.textContent || "";
const date_match = text.match(/(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})/);
if (date_match) {
return date_match[1];
}
}
throw new Error("Date pattern not found in either old or new format");
}
/**
* Parse content from floor element.
*/
_parse_content(floor_element) {
const content_element = floor_element.querySelector(".d_post_content");
if (!content_element) {
throw new Error("Content element not found");
}
let content = content_element.innerHTML.trim();
content = content.replace(/点击展开,查看完整图片。/g, "");
content = content.replace(/点击展开,查看完整图片<\/a>/g, "");
content = content.replace(/\s*点击展开,查看完整图片。?\s*/g, "");
content = content.replace(/\s*点击展开,查看完整图片<\/a>\s*/g, "");
return content;
}
/**
* Parse IP location from floor element.
* Returns undefined if IP location not present.
* Handles both old and new Tieba DOM formats.
*/
_parse_ip_location(floor_element) {
const ip_element = floor_element.querySelector(".ip-location span");
if (ip_element) {
const text = ip_element.textContent || "";
const ip_match = text.match(/IP属地:(.+)/);
if (ip_match) {
return ip_match[1].trim();
}
}
const ip_spans = floor_element.querySelectorAll("span");
for (const span of Array.from(ip_spans)) {
const text = span.textContent || "";
const ip_match = text.match(/IP属地:(.+)/);
if (ip_match) {
return ip_match[1].trim();
}
}
return void 0;
}
/**
* Parse floor index from floor element.
* Handles both old and new Tieba DOM formats.
*/
_parse_floor_index(floor_element) {
const new_format_tail = floor_element.querySelector(".p_tail");
if (new_format_tail) {
const floor_span = new_format_tail.querySelector("span");
if (floor_span) {
const text = floor_span.textContent || "";
const floor_match = text.match(/(\d+)楼/);
if (floor_match) {
return parseInt(floor_match[1]);
}
}
}
const tail_info_spans = Array.from(floor_element.querySelectorAll(".tail-info"));
for (const span of tail_info_spans) {
const text = span.textContent || "";
const floor_match = text.match(/(\d+)楼/);
if (floor_match) {
return parseInt(floor_match[1]);
}
}
throw new Error("Floor index pattern not found in either old or new format");
}
/**
* Parse sub-comments from floor element.
* Handles pagination by clicking through all pages.
*/
async _parse_sub_comments(floor_element) {
const all_sub_comments = [];
try {
const pager = floor_element.querySelector(".j_pager");
if (!pager) {
return this._parse_current_sub_comments(floor_element);
}
const pager_html = pager;
if (pager_html.style.display === "none") {
return this._parse_current_sub_comments(floor_element);
}
const total_pages = this._get_sub_comment_page_count(pager);
if (total_pages === 0) {
return this._parse_current_sub_comments(floor_element);
}
const first_page_comments = this._parse_current_sub_comments(floor_element);
all_sub_comments.push(...first_page_comments);
for (let page = 2; page <= total_pages; page++) {
const page_link = this._find_page_button(floor_element, page);
if (!page_link) {
break;
}
page_link.click();
await this._wait_for_dom_update();
const page_comments = this._parse_current_sub_comments(floor_element);
all_sub_comments.push(...page_comments);
}
} catch {
return this._parse_current_sub_comments(floor_element);
}
return all_sub_comments;
}
/**
* Get total page count from sub-comment pager.
*/
_get_sub_comment_page_count(pager) {
const last_page_links = Array.from(pager.querySelectorAll("a"));
for (const link of last_page_links) {
const text = link.textContent?.trim();
if (text === "\u5C3E\u9875" || text === "Last") {
const href = link.getAttribute("href");
if (href) {
const match = href.match(/#(\d+)/);
if (match) {
return parseInt(match[1]);
}
}
}
}
const page_numbers = [];
const all_links = Array.from(pager.querySelectorAll("a"));
for (const link of all_links) {
const text = link.textContent?.trim();
if (text && /^\d+$/.test(text)) {
page_numbers.push(parseInt(text));
}
}
if (page_numbers.length > 0) {
return Math.max(...page_numbers);
}
return 0;
}
/**
* Find specific page button for sub-comment pagination.
*/
_find_page_button(floor_element, page_num) {
const pager = floor_element.querySelector(".j_pager");
if (!pager) {
return null;
}
const page_links = Array.from(pager.querySelectorAll("a"));
for (const link of page_links) {
const href = link.getAttribute("href");
if (href === `#${page_num}`) {
return link;
}
}
for (const link of page_links) {
const text = link.textContent?.trim();
if (text === page_num.toString()) {
return link;
}
}
return null;
}
/**
* Parse sub-comments currently visible in DOM.
*/
_parse_current_sub_comments(floor_element) {
const sub_comments = [];
const sub_comment_elements = floor_element.querySelectorAll(".lzl_single_post, .j_lzl_s_p");
sub_comment_elements.forEach((sub_element) => {
try {
const comment = this._parse_sub_comment(sub_element);
sub_comments.push(comment);
} catch {
}
});
return sub_comments;
}
/**
* Wait for DOM to update after pagination click.
*/
async _wait_for_dom_update() {
return new Promise((resolve) => {
setTimeout(resolve, 1e3);
});
}
/**
* Parse single sub-comment from DOM element.
*/
_parse_sub_comment(sub_element) {
const author = this._parse_sub_author(sub_element);
const date = this._parse_sub_date(sub_element);
const content = this._parse_sub_content(sub_element);
const avatar_url = this._parse_sub_avatar_url(sub_element);
const homepage_url = this._parse_sub_homepage_url(sub_element);
const reply_info = this._parse_reply_info(content);
return {
author,
date,
content,
avatar_url,
homepage_url,
reply_to_author: reply_info.author,
reply_to_homepage_url: reply_info.homepage_url
};
}
/**
* Parse sub-comment avatar URL.
*/
_parse_sub_avatar_url(sub_element) {
const avatar_link = sub_element.querySelector(".lzl_p_p img");
if (avatar_link) {
const src = avatar_link.getAttribute("src");
if (src) {
if (src.startsWith("//")) {
return "https:" + src;
}
return src;
}
}
return void 0;
}
/**
* Parse sub-comment homepage URL.
*/
_parse_sub_homepage_url(sub_element) {
const user_card = sub_element.querySelector(".at.j_user_card");
if (user_card && user_card.tagName === "A") {
const href = user_card.getAttribute("href");
if (href && href.includes("/home/main")) {
if (href.startsWith("/")) {
return "https://tieba.baidu.com" + href;
}
return href;
}
}
const home_link = sub_element.querySelector('a[href*="/home/main"]');
if (home_link) {
const href = home_link.getAttribute("href");
if (href) {
if (href.startsWith("/")) {
return "https://tieba.baidu.com" + href;
}
return href;
}
}
return void 0;
}
/**
* Parse reply-to information from sub-comment content.
* Returns the author and homepage URL if this is a reply to another user.
*/
_parse_reply_info(content) {
const reply_pattern = /回复\s+<a[^>]+href="([^"]+)"[^>]*>([^<]+)<\/a>\s*:/;
const match = content.match(reply_pattern);
if (match) {
let href = match[1];
const author = match[2];
if (href.startsWith("/")) {
href = "https://tieba.baidu.com" + href;
}
return { author, homepage_url: href };
}
return {};
}
/**
* Parse sub-comment author name.
*/
_parse_sub_author(sub_element) {
const user_card = sub_element.querySelector(".at.j_user_card, .j_user_card");
if (user_card) {
const data_field = user_card.getAttribute("data-field");
if (data_field) {
try {
const parsed = JSON.parse(data_field.replace(/'/g, '"'));
if (parsed.showname) {
return parsed.showname;
}
} catch {
}
}
const text = user_card.textContent?.trim();
if (text) {
return text;
}
}
const author_span = sub_element.querySelector("[data-lzl-author]");
if (author_span) {
const username = author_span.getAttribute("data-lzl-author");
if (username) {
return username;
}
}
const any_user = sub_element.querySelector("[username]");
if (any_user) {
const username = any_user.getAttribute("username");
if (username) {
return username;
}
}
return "Unknown";
}
/**
* Parse sub-comment date.
* Format: "2026-3-28 11:02" (no zero-padding)
*/
_parse_sub_date(sub_element) {
const time_element = sub_element.querySelector(".lzl_time");
if (!time_element) {
throw new Error("Sub-comment date not found");
}
const text = (time_element.textContent || "").replace(/\u00a0/, " ");
return text.trim();
}
/**
* Parse sub-comment content.
*/
_parse_sub_content(sub_element) {
const content_element = sub_element.querySelector(".lzl_content_main");
if (!content_element) {
throw new Error("Sub-comment content not found");
}
return content_element.innerHTML.trim();
}
};
// src/main.ts
var DownloadModal = class {
constructor() {
this.modal = document.createElement("div");
this.modal.id = "tieba-download-modal";
this.modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: none;
justify-content: center;
align-items: center;
z-index: 10000;
`;
const content = document.createElement("div");
content.style.cssText = `
background: white;
padding: 30px;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 90%;
`;
const title = document.createElement("h2");
title.textContent = "\u5907\u4EFD\u5B8C\u6210";
title.style.cssText = `
margin: 0 0 20px 0;
color: #333;
text-align: center;
`;
this.summary_text = document.createElement("div");
this.summary_text.style.cssText = `
margin-bottom: 20px;
color: #666;
line-height: 1.6;
`;
const progress_container = document.createElement("div");
progress_container.style.cssText = `
margin-bottom: 15px;
`;
const progress_bg = document.createElement("div");
progress_bg.style.cssText = `
width: 100%;
height: 24px;
background: #e0e0e0;
border-radius: 12px;
overflow: hidden;
position: relative;
`;
this.progress_bar = document.createElement("div");
this.progress_bar.style.cssText = `
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
width: 0%;
transition: width 0.3s ease;
`;
const progress_overlay = document.createElement("div");
progress_overlay.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 12px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
`;
progress_overlay.id = "tieba-progress-overlay";
progress_overlay.textContent = "0%";
this.progress_text = document.createElement("div");
this.progress_text.style.cssText = `
text-align: center;
color: #999;
font-size: 14px;
margin-top: 10px;
`;
progress_bg.appendChild(this.progress_bar);
progress_bg.appendChild(progress_overlay);
progress_container.appendChild(progress_bg);
content.appendChild(title);
content.appendChild(this.summary_text);
content.appendChild(progress_container);
content.appendChild(this.progress_text);
this.modal.appendChild(content);
document.body.appendChild(this.modal);
}
/**
* Show modal with summary info
*/
show(post, image_count) {
this.summary_text.innerHTML = `
<div><strong>\u8D34\u5B50\u6807\u9898:</strong> ${post.title}</div>
<div><strong>\u8D34\u5427:</strong> ${post.tieba_name}</div>
<div><strong>\u697C\u5C42:</strong> ${post.floor_list.length}</div>
<div><strong>\u56DE\u590D:</strong> ${post.comment_cnt}</div>
<div><strong>\u56FE\u7247:</strong> ${image_count}</div>
`;
this.modal.style.display = "flex";
this.progress_text.textContent = "\u6B63\u5728\u4E0B\u8F7D\u56FE\u7247...";
}
/**
* Update progress bar
*/
update_progress(current, total, filename) {
const percentage = Math.round(current / total * 100);
this.progress_bar.style.width = `${percentage}%`;
const overlay = document.getElementById("tieba-progress-overlay");
if (overlay) {
overlay.textContent = `${percentage}%`;
}
this.progress_text.textContent = `\u6B63\u5728\u4E0B\u8F7D: ${filename} (${current}/${total})`;
}
/**
* Hide modal
*/
hide() {
this.modal.style.display = "none";
}
/**
* Show complete message
*/
show_complete(filename) {
this.progress_text.textContent = `\u2705 \u5907\u4EFD\u5B8C\u6210! \u6B63\u5728\u4E0B\u8F7D: ${filename}`;
this.progress_bar.style.width = "100%";
const overlay = document.getElementById("tieba-progress-overlay");
if (overlay) {
overlay.textContent = "100%";
}
}
};
var download_modal = null;
async function backup_post() {
const backup_button = document.getElementById("tieba-backup-btn");
try {
const parser = new Parser();
if (backup_button) {
backup_button.textContent = "\u5907\u4EFD\u4E2D...";
backup_button.disabled = true;
}
const post = await parser.parse_post();
if (!download_modal) {
download_modal = new DownloadModal();
}
const image_handler = parser.get_image_handler();
const image_map = image_handler.image_map || {};
const image_count = Object.keys(image_map).length;
download_modal.show(post, image_count);
const image_blobs = await image_handler.download_images((current, total, filename2) => {
if (download_modal) {
download_modal.update_progress(current, total, filename2);
}
});
const viewer_html = image_handler.create_viewer_html(post);
const json_data = JSON.stringify(post, null, 2);
const files = {
"post.json": json_data,
"viewer.html": viewer_html
};
for (const [name, blob] of Object.entries(image_blobs)) {
files[`images/${name}`] = blob;
}
const tar_blob = await image_handler.create_tar(files);
const filename = `${post.tieba_name}_${post.id}.tar`;
if (download_modal) {
download_modal.show_complete(filename);
}
await new Promise((resolve) => setTimeout(resolve, 500));
image_handler.download_blob(tar_blob, filename);
setTimeout(() => {
if (download_modal) {
download_modal.hide();
}
}, 2e3);
if (backup_button) {
backup_button.textContent = "\u5907\u4EFD\u672C\u8D34";
backup_button.disabled = false;
}
return post;
} catch (error) {
if (error instanceof Error && error.message === "NAVIGATING") {
return void 0;
}
if (error instanceof Error) {
const error_info = {
message: error.message,
stack: error.stack,
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
url: window.location.href,
user_agent: navigator.userAgent
};
window.GM_setValue("last_error", error_info);
}
if (download_modal) {
download_modal.hide();
}
if (backup_button) {
backup_button.textContent = "\u5907\u4EFD\u5931\u8D25";
setTimeout(() => {
backup_button.textContent = "\u5907\u4EFD\u672C\u8D34";
backup_button.disabled = false;
}, 2e3);
}
show_view_error_button();
throw error;
}
}
function inject_backup_button() {
if (document.getElementById("tieba-backup-btn")) {
return;
}
const container = document.createElement("div");
container.id = "tieba-backup-container";
container.style.cssText = `
position: fixed;
top: 100px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
`;
const backup_button = document.createElement("button");
backup_button.id = "tieba-backup-btn";
backup_button.textContent = "\u5907\u4EFD\u672C\u8D34";
backup_button.style.cssText = `
padding: 10px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
`;
backup_button.addEventListener("mouseenter", () => {
backup_button.style.transform = "translateY(-2px)";
backup_button.style.boxShadow = "0 6px 12px rgba(0, 0, 0, 0.15)";
});
backup_button.addEventListener("mouseleave", () => {
backup_button.style.transform = "translateY(0)";
backup_button.style.boxShadow = "0 4px 6px rgba(0, 0, 0, 0.1)";
});
backup_button.addEventListener("click", () => {
backup_post();
});
const reset_button = document.createElement("button");
reset_button.id = "tieba-reset-btn";
reset_button.textContent = "\u91CD\u7F6E\u72B6\u6001";
reset_button.style.cssText = `
padding: 8px 16px;
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
opacity: 0.8;
`;
reset_button.addEventListener("mouseenter", () => {
reset_button.style.transform = "translateY(-2px)";
reset_button.style.boxShadow = "0 4px 8px rgba(0, 0, 0, 0.15)";
reset_button.style.opacity = "1";
});
reset_button.addEventListener("mouseleave", () => {
reset_button.style.transform = "translateY(0)";
reset_button.style.boxShadow = "0 2px 4px rgba(0, 0, 0, 0.1)";
reset_button.style.opacity = "0.8";
});
reset_button.addEventListener("click", () => {
const parser = new Parser();
parser.reset_parsing();
reset_button.textContent = "\u5DF2\u91CD\u7F6E";
setTimeout(() => {
reset_button.textContent = "\u91CD\u7F6E\u72B6\u6001";
}, 1e3);
});
container.appendChild(backup_button);
container.appendChild(reset_button);
document.body.appendChild(container);
}
function auto_resume_if_needed() {
const parser = new Parser();
if (parser.is_parsing_in_progress()) {
const reset_button = document.getElementById("tieba-reset-btn");
if (reset_button) {
reset_button.style.opacity = "1";
reset_button.style.fontWeight = "bold";
}
setTimeout(() => {
if (parser.is_parsing_in_progress()) {
backup_post();
}
}, 2e3);
return true;
}
return false;
}
function show_view_error_button() {
if (document.getElementById("tieba-view-error-btn")) {
return;
}
const container = document.getElementById("tieba-backup-container");
if (!container) {
return;
}
const error_button = document.createElement("button");
error_button.id = "tieba-view-error-btn";
error_button.textContent = "\u67E5\u770B\u9519\u8BEF";
error_button.style.cssText = `
padding: 8px 16px;
background: #ff4444;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: bold;
opacity: 0.9;
`;
error_button.addEventListener("click", () => {
const error_data = window.GM_getValue("last_error", void 0);
if (!error_data) {
alert("\u6CA1\u6709\u9519\u8BEF\u4FE1\u606F");
return;
}
const error_text = `
\u9519\u8BEF\u4FE1\u606F: ${error_data.message}
\u65F6\u95F4: ${error_data.timestamp}
\u9875\u9762: ${error_data.url}
\u6D4F\u89C8\u5668: ${error_data.user_agent}
\u5806\u6808:
${error_data.stack}
`.trim();
alert(error_text);
});
const reset_button = document.getElementById("tieba-reset-btn");
if (reset_button) {
container.insertBefore(error_button, reset_button);
} else {
container.appendChild(error_button);
}
setTimeout(() => {
error_button.remove();
}, 3e4);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
inject_backup_button();
auto_resume_if_needed();
});
} else {
inject_backup_button();
auto_resume_if_needed();
}
window.backup_post = backup_post;
})();