吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 107|回复: 2
上一主题 下一主题
收起左侧

[其他原创] 【python】七猫免费小说下载器

[复制链接]
跳转到指定楼层
楼主
Willian 发表于 2026-11-6 19:51 回帖奖励
本帖最后由 Willian 于 2026-11-6 20:53 编辑


[Python] 纯文本查看 复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""七猫小说下载器

运行后按提示输入 book_id 或目录 URL,选择要下载的章节即可,
下载完成后自动回到输入界面(直接回车退出,输入 cookie 可重填登录信息)。
结果按章节正序写入脚本所在目录下的“书名.txt”。
会员章节需要登录 Cookie,首次运行会提示填写并保存到 qimao_cookie.txt。
依赖:pip install requests pycryptodome
"""

import base64
import hashlib
import html as html_lib
import os
import re
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from html.parser import HTMLParser

try:
    import requests
except ImportError:
    sys.exit('缺少依赖:pip install requests')

try:
    from Crypto.Cipher import DES
    from Crypto.Util.Padding import unpad
    HAS_CRYPTO = True
except ImportError:
    HAS_CRYPTO = False

THREADS = 8
OUT_DIR = os.path.dirname(os.path.abspath(__file__))
COOKIE_FILE_NAME = 'qimao_cookie.txt'

API_BOOK_INFO = 'https://www.qimao.com/qimaoapi/api/book-detail/main-info?book_id={book_id}'
API_CHAPTER_LIST = 'https://www.qimao.com/qimaoapi/api/book/chapter-list?book_id={book_id}'
API_CHAPTER = 'https://www.qimao.com/qimaoapi/api/book/chapter'
PAGE_CHAPTER = 'https://www.qimao.com/shuku/{book_id}-{chapter_id}/'
PAGE_BOOK = 'https://www.qimao.com/shuku/{book_id}/'

UA = ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
      '(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36')

JSON_HEADERS = {
    'User-Agent': UA,
    'Accept': 'application/json, text/plain, */*',
    'Accept-Language': 'zh-CN,zh;q=0.9',
    'Referer': 'https://www.qimao.com/',
    'Origin': 'https://www.qimao.com',
}

WORDS_TOLERANCE = 0.05
SHORT_RATIO = 0.9
MAX_RETRY = 3

FAIL_HINT = '[本章内容获取失败:该章为会员章节,请重新填写有会员权限的 Cookie 后重试]'
SHORT_HINT = '[提示:本章只获取到试读内容,完整正文需要会员权限的 Cookie]'

_local = threading.local()
_cookie = ''
_print_lock = threading.Lock()


class ArticleParser(HTMLParser):
    """按 <p> 段落切分正文 HTML。"""

    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.paragraphs = []
        self._cur = None

    def handle_starttag(self, tag, attrs):
        if tag == 'p':
            self._cur = []
        elif tag == 'br' and self._cur is not None:
            self._cur.append('\n')

    def handle_startendtag(self, tag, attrs):
        if tag == 'br' and self._cur is not None:
            self._cur.append('\n')

    def handle_endtag(self, tag):
        if tag == 'p' and self._cur is not None:
            text = clean_text(''.join(self._cur))
            if text:
                self.paragraphs.append(text)
            self._cur = None

    def handle_data(self, data):
        if self._cur is not None:
            self._cur.append(data)


def clean_text(s):
    s = html_lib.unescape(s or '')
    s = s.replace('\u3000', ' ').replace('\xa0', ' ')
    return s.strip()


def strip_tags(s):
    return clean_text(re.sub(r'<[^>]+>', '', s or ''))


def count_chars(text):
    return len(re.sub(r'\s', '', text or ''))


def html_to_paragraphs(content):
    parser = ArticleParser()
    try:
        parser.feed(content or '')
        parser.close()
    except Exception:
        pass
    if parser.paragraphs:
        return parser.paragraphs
    return [line for line in (strip_tags(content) or '').split('\n') if line.strip()]


def decrypt_vip_content(cipher_b64, key):
    """DES/ECB/PKCS7,密钥取 MD5(key) 前 8 字节,密文为双层 base64。"""
    if not HAS_CRYPTO:
        raise RuntimeError('解密会员章节需要 pycryptodome:pip install pycryptodome')
    inner = base64.b64decode(cipher_b64).decode('utf-8')
    blob = base64.b64decode(inner)
    des_key = hashlib.md5(key.encode('utf-8')).hexdigest().encode('utf-8')[:8]
    plain = unpad(DES.new(des_key, DES.MODE_ECB).decrypt(blob), DES.block_size)
    return plain.decode('utf-8')


def extract_chapter_data(page_html):
    marker = 'chapterData:"'
    i = page_html.find(marker)
    if i < 0:
        return ''
    start = i + len(marker)
    end = page_html.find('"', start)
    if end < 0:
        return ''
    raw = page_html[start:end]
    return (raw.replace('\\u002F', '/')
               .replace('\\u003D', '=')
               .replace('\\u002B', '+')
               .replace('\\/', '/'))


def get_session():
    session = getattr(_local, 'session', None)
    if session is None:
        session = requests.Session()
        session.headers.update(JSON_HEADERS)
        _local.session = session
    if _cookie:
        if session.headers.get('Cookie') != _cookie:
            session.headers['Cookie'] = _cookie
    elif 'Cookie' in session.headers:
        del session.headers['Cookie']
    return session


def fetch_book_name(book_id):
    try:
        r = get_session().get(API_BOOK_INFO.format(book_id=book_id), timeout=20)
        detail = (r.json().get('data') or {}).get('book_detail') or {}
        name = detail.get('title') or detail.get('book_title') or detail.get('book_name')
        if name:
            return strip_tags(name)
    except Exception:
        pass
    return str(book_id)


def fetch_chapters(book_id):
    r = get_session().get(API_CHAPTER_LIST.format(book_id=book_id), timeout=30)
    r.raise_for_status()
    chapters = ((r.json().get('data') or {}).get('chapters')) or []
    for ch in chapters:
        ch['index'] = int(ch.get('index') or 0)
        ch['is_vip'] = str(ch.get('is_vip'))
        ch['words'] = int(ch.get('words') or 0)
    chapters.sort(key=lambda c: (c['index'], str(c['id'])))
    return chapters


def pick_status(paragraphs, expect):
    if not paragraphs:
        return 'empty'
    got = count_chars('\n'.join(paragraphs))
    if not expect or got >= expect * (1 - WORDS_TOLERANCE):
        return 'ok'
    if got >= expect * SHORT_RATIO:
        return 'ok'
    return 'short'


def fetch_free_chapter(book_id, chapter):
    best = ([], 'empty')
    referer = PAGE_CHAPTER.format(book_id=book_id, chapter_id=chapter['id'])

    for attempt in range(1, MAX_RETRY + 1):
        try:
            r = get_session().post(
                API_CHAPTER,
                data={'book_id': book_id, 'chapter_id': chapter['id']},
                headers={'Content-Type': 'application/x-www-form-urlencoded', 'Referer': referer},
                timeout=25,
            )
            content = ((r.json().get('data') or {}).get('content')) or ''
        except Exception:
            content = ''
            time.sleep(0.6 * attempt)

        paragraphs = html_to_paragraphs(content) if '<p' in content else []
        status = pick_status(paragraphs, chapter['words'])
        if status != 'empty':
            best = (paragraphs, status)
            if status == 'ok':
                return best
        if attempt < MAX_RETRY:
            time.sleep(0.4 * attempt)

    return best


def fetch_vip_chapter(book_id, chapter):
    best = ([], 'empty')
    url = PAGE_CHAPTER.format(book_id=book_id, chapter_id=chapter['id'])

    for attempt in range(1, MAX_RETRY + 1):
        try:
            r = get_session().get(
                url,
                headers={
                    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
                    'Referer': PAGE_BOOK.format(book_id=book_id),
                },
                timeout=25,
            )
            page = r.text
        except Exception:
            page = ''
            time.sleep(0.6 * attempt)

        data = extract_chapter_data(page) if page else ''
        text = ''
        if len(data) > 32:
            try:
                text = decrypt_vip_content(data[:-32], data[-32:])
            except Exception:
                text = ''

        paragraphs = html_to_paragraphs(text) if text else []
        status = pick_status(paragraphs, chapter['words'])
        if status != 'empty':
            best = (paragraphs, status)
            if status == 'ok':
                return best
        if attempt < MAX_RETRY:
            time.sleep(0.4 * attempt)

    return best


def fetch_chapter(book_id, chapter):
    if chapter['is_vip'] == '1':
        return fetch_vip_chapter(book_id, chapter)
    return fetch_free_chapter(book_id, chapter)


def parse_book_id(raw):
    raw = (raw or '').strip()
    m = re.search(r'/shuku/(\d+)', raw)
    if m:
        return m.group(1)
    if re.fullmatch(r'\d{4,}', raw):
        return raw
    return None


def parse_selection(expr, chapters):
    expr = (expr or '').strip().lower()
    if expr in ('', 'all', '*'):
        return list(chapters)
    if expr == 'free':
        return [c for c in chapters if c['is_vip'] == '0']
    if expr == 'vip':
        return [c for c in chapters if c['is_vip'] == '1']

    by_index = {c['index']: c for c in chapters}
    picked, seen = [], set()
    for part in re.split(r'[,,\s]+', expr):
        if not part:
            continue
        m = re.fullmatch(r'(\d+)\s*[-~]\s*(\d+)', part)
        if m:
            a, b = int(m.group(1)), int(m.group(2))
            if a > b:
                a, b = b, a
            for i in range(a, b + 1):
                if i in by_index and i not in seen:
                    seen.add(i)
                    picked.append(by_index[i])
            continue
        if part.isdigit():
            i = int(part)
            if i in by_index and i not in seen:
                seen.add(i)
                picked.append(by_index[i])
            continue
        raise ValueError('无法识别的章节选择:%s' % part)
    picked.sort(key=lambda c: c['index'])
    return picked


def print_chapters(chapters, limit=None):
    rows = chapters if limit is None else chapters[:limit]
    for ch in rows:
        print('  %5d  %-40s %s  (%s字)' % (
            ch['index'], ch['title'].strip(),
            '[会员]' if ch['is_vip'] == '1' else '[免费]', ch['words']))
    if limit is not None and len(chapters) > limit:
        print('  ...(共 %d 章,输入 list 查看全部)' % len(chapters))


def cookie_file_path():
    return os.path.join(OUT_DIR, COOKIE_FILE_NAME)


def normalize_cookie(raw):
    raw = (raw or '').strip().strip('"').strip("'").replace('\r', '').replace('\n', '')
    if not raw:
        return ''
    if raw.lower().startswith('cookie:'):
        raw = raw[7:].strip()
    if '=' not in raw:
        return 'qimao-token=' + raw
    return raw


def read_cookie_file(path):
    try:
        with open(path, 'r', encoding='utf-8') as f:
            return normalize_cookie(f.read())
    except Exception:
        return ''


def save_cookie_file(path, cookie):
    with open(path, 'w', encoding='utf-8') as f:
        f.write(cookie + '\n')


def prompt_cookie(path):
    print('')
    print('会员章节需要登录 Cookie。')
    print('浏览器登录 [url=https://www.qimao.com/]https://www.qimao.com/[/url] 后按 F12 → Network → 任选一个请求,')
    print('复制请求头 Cookie 里 qimao-token 的值,粘到下面(只下免费章节可直接回车)。')
    try:
        raw = input('Cookie:')
    except (EOFError, KeyboardInterrupt):
        print('\n已跳过')
        return ''

    cookie = normalize_cookie(raw)
    if not cookie:
        print('未填写,稍后可手动写入:%s' % path)
        return ''
    try:
        save_cookie_file(path, cookie)
        print('已保存到:%s' % path)
    except Exception as e:
        print('保存失败:%s,本次仍使用已输入的 Cookie' % e)
    return cookie


def load_cookie():
    path = cookie_file_path()
    cookie = read_cookie_file(path)
    if cookie:
        return cookie
    return prompt_cookie(path)


def safe_filename(name):
    name = re.sub(r'[\\/:*?"<>|\r\n\t]', '_', (name or '').strip())
    return name.strip(' .') or 'qimao_novel'


def download_book(book_id):
    print('book_id = %s' % book_id)
    print('正在获取目录...')
    try:
        chapters = fetch_chapters(book_id)
    except Exception as e:
        print('获取目录失败:%s' % e)
        return
    if not chapters:
        print('目录为空,book_id 可能有误')
        return

    book_name = fetch_book_name(book_id)
    free_cnt = sum(1 for c in chapters if c['is_vip'] == '0')
    print('《%s》 共 %d 章(免费 %d / 会员 %d)'
          % (book_name, len(chapters), free_cnt, len(chapters) - free_cnt))
    print_chapters(chapters, limit=10)

    expr = None
    while True:
        if expr is None:
            expr = input('\n请选择要下载的章节'
                         '(all=全部, free=仅免费, vip=仅会员, 例: 1-20,25,30-40;list=查看目录):').strip()
        if expr.lower() == 'list':
            print_chapters(chapters)
            expr = None
            continue
        try:
            selected = parse_selection(expr, chapters)
        except ValueError as e:
            print(e)
            expr = None
            continue
        if not selected:
            print('没有匹配到章节,请重新选择')
            expr = None
            continue
        break

    if not _cookie and any(c['is_vip'] == '1' for c in selected):
        print('提示:所选含会员章节,未填写 Cookie 时只能拿到试读。')

    print('开始下载 %d 章,线程数 %d' % (len(selected), THREADS))
    results = {}
    empty, short = [], []
    done, total = 0, len(selected)
    t0 = time.time()

    with ThreadPoolExecutor(max_workers=THREADS) as pool:
        futures = {pool.submit(fetch_chapter, book_id, ch): ch for ch in selected}
        for fut in as_completed(futures):
            ch = futures[fut]
            try:
                paragraphs, status = fut.result()
            except Exception:
                paragraphs, status = [], 'empty'
            results[ch['index']] = (ch, paragraphs, status)
            done += 1
            if status == 'empty':
                empty.append(ch)
                flag = '失败'
            elif status == 'short':
                short.append(ch)
                flag = '试读'
            else:
                flag = 'OK'
            with _print_lock:
                print('\r[%d/%d] 第%d章 %s -> %s            ' % (
                    done, total, ch['index'], ch['title'].strip()[:22], flag), end='', flush=True)
    print('\n下载完成,用时 %.1fs' % (time.time() - t0))

    os.makedirs(OUT_DIR, exist_ok=True)
    out_path = os.path.join(OUT_DIR, safe_filename(book_name) + '.txt')

    lines = []
    for index in sorted(results):
        ch, paragraphs, status = results[index]
        lines.append(ch['title'].strip())
        if paragraphs:
            lines.extend(paragraphs)
            if status == 'short':
                lines.append(SHORT_HINT)
        else:
            lines.append(FAIL_HINT)
        lines.append('')

    with open(out_path, 'w', encoding='utf-8-sig', newline='\n') as f:
        f.write('\n'.join(lines))

    ok_cnt = sum(1 for _, _, s in results.values() if s == 'ok')
    print('已写入:%s' % out_path)
    print('成功 %d 章 / 共 %d 章(完整 %d,试读 %d,失败 %d)'
          % (total - len(empty), total, ok_cnt, len(short), len(empty)))
    if short:
        print('以下 %d 章只拿到试读(需要会员权限 Cookie):%s'
              % (len(short), ', '.join('第%d章' % c['index'] for c in short[:20])
                 + (' ...' if len(short) > 20 else '')))
    if empty:
        print('以下 %d 章没有取到内容:%s'
              % (len(empty), ', '.join('第%d章' % c['index'] for c in empty[:20])
                 + (' ...' if len(empty) > 20 else '')))


def main():
    global _cookie

    _cookie = load_cookie()

    while True:
        try:
            raw = input('\n请输入 book_id 或 目录URL(回车退出,输入 cookie 重填登录信息):').strip()
        except (EOFError, KeyboardInterrupt):
            print('\n已退出')
            return 0

        if not raw or raw.lower() in ('q', 'quit', 'exit'):
            print('已退出')
            return 0
        if raw.lower() in ('cookie', 'setcookie'):
            _cookie = prompt_cookie(cookie_file_path()) or _cookie
            continue

        book_id = parse_book_id(raw)
        if not book_id:
            print('无法识别 book_id,请重新输入')
            continue

        try:
            download_book(book_id)
        except (EOFError, KeyboardInterrupt):
            print('\n已退出')
            return 0


if __name__ == '__main__':
    sys.exit(main())


成品打包
https://xybyg.lanzoul.com/iefOz4eyeq6j
密码: djf3
本程序由deepseekai辅助完成,仅作学习使用,严禁商用,后果自负!

发帖前要善用【论坛搜索】功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
wen2077108 发表于 2026-11-6 20:32
在那下载呢?
3#
 楼主| Willian 发表于 2026-11-6 20:53 |楼主
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-11-6 20:54

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表