shindan_maker/
internal.rs

1use anyhow::{Context, Result};
2use scraper::{Html, Node, Selector};
3use std::sync::OnceLock;
4
5static SELECTORS: OnceLock<Selectors> = OnceLock::new();
6
7struct Selectors {
8    shindan_title: Selector,
9    shindan_description: Selector,
10    form_inputs: Vec<Selector>,
11    input_parts: Selector,
12    #[cfg(feature = "segments")]
13    shindan_result: Selector,
14    #[cfg(feature = "html")]
15    title_and_result: Selector,
16    #[cfg(feature = "html")]
17    script: Selector,
18    #[cfg(feature = "html")]
19    effects: Vec<Selector>,
20    #[cfg(feature = "html")]
21    merged_image: Selector,
22}
23
24impl Selectors {
25    fn get() -> &'static Self {
26        SELECTORS.get_or_init(|| Self {
27            shindan_title: Selector::parse("#shindanTitle").expect("Valid Selector"),
28            shindan_description: Selector::parse("#shindanDescriptionDisplay")
29                .expect("Valid Selector"),
30            form_inputs: vec![
31                Selector::parse("input[name=_token]").unwrap(),
32                Selector::parse("input[name=randname]").unwrap(),
33                Selector::parse("input[name=type]").unwrap(),
34            ],
35            input_parts: Selector::parse(r#"input[name^="parts["]"#).unwrap(),
36
37            #[cfg(feature = "segments")]
38            shindan_result: Selector::parse("#shindanResult").expect("Valid Selector"),
39
40            #[cfg(feature = "html")]
41            title_and_result: Selector::parse("#title_and_result").expect("Valid Selector"),
42            #[cfg(feature = "html")]
43            script: Selector::parse("script").expect("Valid Selector"),
44            #[cfg(feature = "html")]
45            effects: vec![
46                Selector::parse("span.shindanEffects[data-mode=ef_typing]").unwrap(),
47                Selector::parse("span.shindanEffects[data-mode=ef_shuffle]").unwrap(),
48            ],
49            #[cfg(feature = "html")]
50            merged_image: Selector::parse("span.v1-merged-image").unwrap(),
51        })
52    }
53}
54
55pub(crate) fn extract_title(dom: &Html) -> Result<String> {
56    Ok(dom
57        .select(&Selectors::get().shindan_title)
58        .next()
59        .context("Failed to find shindanTitle element")?
60        .value()
61        .attr("data-shindan_title")
62        .context("Missing data-shindan_title attribute")?
63        .to_string())
64}
65
66pub(crate) fn extract_description(dom: &Html) -> Result<String> {
67    let mut desc = Vec::new();
68    let element = dom
69        .select(&Selectors::get().shindan_description)
70        .next()
71        .context("Failed to find description element")?;
72
73    for child in element.children() {
74        match child.value() {
75            Node::Text(text) => desc.push(text.to_string()),
76            Node::Element(el) if el.name() == "br" => desc.push("\n".to_string()),
77            Node::Element(_) => {
78                if let Some(node) = child.children().next()
79                    && let Node::Text(text) = node.value()
80                {
81                    desc.push(text.to_string());
82                }
83            }
84            _ => {}
85        }
86    }
87    Ok(desc.join(""))
88}
89
90pub(crate) fn extract_form_data(dom: &Html, name: &str) -> Result<Vec<(String, String)>> {
91    let selectors = Selectors::get();
92    let fields = ["_token", "randname", "type"];
93    let mut form_data = Vec::with_capacity(fields.len() + 2);
94
95    for (i, &field) in fields.iter().enumerate() {
96        let val = dom
97            .select(&selectors.form_inputs[i])
98            .next()
99            .and_then(|el| el.value().attr("value"))
100            .unwrap_or("")
101            .to_string();
102        form_data.push((field.to_string(), val));
103    }
104
105    form_data.push(("user_input_value_1".to_string(), name.to_string()));
106
107    for el in dom.select(&selectors.input_parts) {
108        if let Some(input_name) = el.value().attr("name") {
109            form_data.push((input_name.to_string(), name.to_string()));
110        }
111    }
112    Ok(form_data)
113}
114
115#[cfg(feature = "segments")]
116pub(crate) fn parse_segments(response_text: &str) -> Result<crate::models::Segments> {
117    use crate::models::{Segment, Segments};
118    use scraper::ElementRef;
119    use serde_json::{Value, json};
120
121    let dom = Html::parse_document(response_text);
122    let mut segments = Vec::new();
123
124    let container_ref = dom
125        .select(&Selectors::get().shindan_result)
126        .next()
127        .context("Failed to find shindanResult")?;
128
129    // Strategy 1: Try parsing the `data-blocks` JSON attribute
130    if let Some(blocks_json) = container_ref.value().attr("data-blocks")
131        && let Ok(blocks) = serde_json::from_str::<Vec<Value>>(blocks_json)
132    {
133        for block in blocks {
134            let type_ = block["type"].as_str().unwrap_or("");
135            match type_ {
136                "text" => {
137                    if let Some(content) = block.get("content").and_then(|v| v.as_str()) {
138                        segments.push(Segment::new("text", json!({ "text": content })));
139                    }
140                }
141                "user_input" => {
142                    if let Some(val) = block.get("value").and_then(|v| v.as_str()) {
143                        segments.push(Segment::new("text", json!({ "text": val })));
144                    }
145                }
146                "image" => {
147                    let src = block
148                        .get("source")
149                        .or(block.get("src"))
150                        .or(block.get("url"))
151                        .or(block.get("file"))
152                        .and_then(|v| v.as_str());
153                    if let Some(s) = src {
154                        segments.push(Segment::new("image", json!({ "file": s })));
155                    }
156                }
157                _ => {}
158            }
159        }
160        if !segments.is_empty() {
161            return Ok(Segments(segments));
162        }
163    }
164
165    // Strategy 2: Fallback to DOM traversal
166    fn extract_nodes(node: ElementRef, segments: &mut Vec<Segment>) {
167        for child in node.children() {
168            match child.value() {
169                Node::Text(text) => {
170                    let t = text.replace("&nbsp;", " ");
171                    if !t.is_empty() {
172                        segments.push(Segment::new("text", json!({ "text": t })));
173                    }
174                }
175                Node::Element(el) => {
176                    if el.name() == "br" {
177                        segments.push(Segment::new("text", json!({ "text": "\n" })));
178                    } else if el.name() == "img" {
179                        let src = el.attr("data-src").or_else(|| el.attr("src"));
180                        if let Some(s) = src {
181                            segments.push(Segment::new("image", json!({ "file": s })));
182                        }
183                    } else if let Some(child_el) = ElementRef::wrap(child) {
184                        extract_nodes(child_el, segments);
185                    }
186                }
187                _ => {}
188            }
189        }
190    }
191
192    extract_nodes(container_ref, &mut segments);
193
194    Ok(Segments(segments))
195}
196
197#[cfg(feature = "html")]
198pub(crate) fn construct_html_result(
199    id: &str,
200    response_text: &str,
201    base_url: &str,
202) -> Result<String> {
203    use anyhow::anyhow;
204    use scraper::Element;
205    use serde_json;
206
207    static APP_CSS: &str = include_str!("../static/app.css");
208    static SHINDAN_JS: &str = include_str!("../static/shindan.js");
209    static APP_JS: &str = include_str!("../static/app.js");
210    static CHART_JS: &str = include_str!("../static/chart.js");
211
212    let dom = Html::parse_document(response_text);
213    let selectors = Selectors::get();
214
215    let mut title_and_result = dom
216        .select(&selectors.title_and_result)
217        .next()
218        .context("Failed to get result element")?
219        .html();
220
221    // 处理特效(移除 noscript)
222    for selector in &selectors.effects {
223        for effect in dom.select(selector) {
224            if let Some(next) = effect.next_sibling_element() {
225                if next.value().name() == "noscript" {
226                    title_and_result = title_and_result
227                        .replace(&effect.html(), "")
228                        .replace(&next.html(), &next.inner_html());
229                }
230            }
231        }
232    }
233
234    // 处理 v1-merged-image (图片未正确渲染的问题)
235    // 逻辑:查找 span -> 获取 json 属性 -> 解析 -> 替换为 img 标签字符串
236    for element in dom.select(&selectors.merged_image) {
237        if let Some(urls_json) = element.value().attr("data-image-urls") {
238            if let Ok(urls) = serde_json::from_str::<Vec<String>>(urls_json) {
239                let mut img_tags = String::new();
240                for url in urls {
241                    img_tags.push_str(&format!(
242                        r#"<img src="{}" class="shindanResult_image" style="max-width: 100%; height: auto; display: inline-block;">"#,
243                        url
244                    ));
245                }
246                title_and_result = title_and_result.replace(&element.html(), &img_tags);
247            }
248        }
249    }
250
251    let mut specific_script = String::new();
252    for element in dom.select(&selectors.script) {
253        let html = element.html();
254        if html.contains(id) {
255            specific_script = html;
256            break;
257        }
258    }
259    if specific_script.is_empty() {
260        return Err(anyhow!("Failed to find script with id {}", id));
261    }
262
263    let mut html = format!(
264        r#"<!DOCTYPE html><html lang="zh" style="height:100%"><head><style>{}</style><meta http-equiv="Content-Type" content="text/html;charset=utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0"><base href="{}"><title>ShindanMaker</title></head><body class="" style="position:relative;min-height:100%;top:0"><div id="main-container"><div id="main">{}</div></div></body><script>{}</script><!-- SCRIPTS --></html>"#,
265        APP_CSS, base_url, title_and_result, SHINDAN_JS
266    );
267
268    if response_text.contains("chart.js") {
269        let scripts = format!(
270            "<script>{}</script>\n<script>{}</script>\n{}",
271            APP_JS, CHART_JS, specific_script
272        );
273        html = html.replace("<!-- SCRIPTS -->", &scripts);
274    }
275
276    Ok(html)
277}