[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())