Skip to content

Commit 4b7895b

Browse files
committed
1 parent c6448b9 commit 4b7895b

File tree

5 files changed

+165
-58
lines changed

5 files changed

+165
-58
lines changed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,26 @@
101101

102102
![](https://upload.cc/i1/2021/03/03/UIDRAQ.png)
103103

104+
# Terminus
105+
106+
除了 Windows Terminal, 还可以试试 [Terminus](https://github.com/eugeny/terminus)
107+
108+
通过 `Settings` > `Shell` > `New Profiles` > `Custom Shell` 添加一个配置
109+
110+
点击新添加的配置, 设置 `Command``D:\Program Files\蓝奏云 CMD\lanzou-cmd.exe`(这里改为实际安装路径),
111+
`Working directory` 可以设置为安装目录或其它目录 `D:\Program Files\蓝奏云 CMD`
112+
113+
![](https://i.bmp.ovh/imgs/2021/05/9338f7feb6b968cc.png)
114+
104115
# 更新日志
105116

117+
## `v2.6.5`
118+
119+
-
120+
修复蓝奏云主域名解析异常的问题[#59](https://github.com/zaxtyson/LanZouCloud-API/issues/59) [#60](https://github.com/zaxtyson/LanZouCloud-API/pull/60)
121+
- 修复某些文件夹信息获取失败的问题[#58](https://github.com/zaxtyson/LanZouCloud-API/pull/58)
122+
- 修复下载页的 Cookie 验证问题[#55](https://github.com/zaxtyson/LanZouCloud-API/pull/55)
123+
106124
## `v2.6.3`
107125

108126
- 修复文件后缀非小写导致的误判问题[#92](https://github.com/rachpt/lanzou-gui/issues/92)

lanzou/api/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from lanzou.api.core import LanZouCloud
22

3-
version = '2.6.2'
3+
version = '2.6.5'
44

55
__all__ = ['utils', 'types', 'models', 'LanZouCloud', 'version']

lanzou/api/core.py

Lines changed: 108 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def __init__(self):
4545
self._timeout = 15 # 每个请求的超时(不包含下载响应体的用时)
4646
self._max_size = 100 # 单个文件大小上限 MB
4747
self._upload_delay = (0, 0) # 文件上传延时
48-
self._host_url = 'https://pan.lanzous.com'
48+
self._host_url = 'https://pan.lanzoui.com'
4949
self._doupload_url = 'https://pc.woozooo.com/doupload.php'
5050
self._account_url = 'https://pc.woozooo.com/account.php'
5151
self._mydisk_url = 'https://pc.woozooo.com/mydisk.php'
@@ -58,20 +58,36 @@ def __init__(self):
5858
disable_warnings(InsecureRequestWarning) # 全局禁用 SSL 警告
5959

6060
def _get(self, url, **kwargs):
61-
try:
62-
kwargs.setdefault('timeout', self._timeout)
63-
kwargs.setdefault('headers', self._headers)
64-
return self._session.get(url, verify=False, **kwargs)
65-
except (ConnectionError, requests.RequestException):
66-
return None
61+
for possible_url in self._all_possible_urls(url):
62+
try:
63+
kwargs.setdefault('timeout', self._timeout)
64+
kwargs.setdefault('headers', self._headers)
65+
return self._session.get(possible_url, verify=False, **kwargs)
66+
except (ConnectionError, requests.RequestException):
67+
logger.debug(f"Get {possible_url} failed, try another domain")
68+
69+
return None
6770

6871
def _post(self, url, data, **kwargs):
69-
try:
70-
kwargs.setdefault('timeout', self._timeout)
71-
kwargs.setdefault('headers', self._headers)
72-
return self._session.post(url, data, verify=False, **kwargs)
73-
except (ConnectionError, requests.RequestException):
74-
return None
72+
for possible_url in self._all_possible_urls(url):
73+
try:
74+
kwargs.setdefault('timeout', self._timeout)
75+
kwargs.setdefault('headers', self._headers)
76+
return self._session.post(possible_url, data, verify=False, **kwargs)
77+
except (ConnectionError, requests.RequestException):
78+
logger.debug(f"Post to {possible_url} ({data}) failed, try another domain")
79+
80+
return None
81+
82+
@staticmethod
83+
def _all_possible_urls(url: str) -> List[str]:
84+
"""蓝奏云的主域名有时会挂掉, 此时尝试切换到备用域名"""
85+
available_domains = [
86+
'lanzoui.com', # 鲁ICP备15001327号-6, 2020-06-09, SEO 排名最低
87+
'lanzoux.com', # 鲁ICP备15001327号-5, 2020-06-09
88+
'lanzous.com' # 主域名, 备案异常, 部分地区已经无法访问
89+
]
90+
return [url.replace('lanzous.com', d) for d in available_domains]
7591

7692
def ignore_limits(self):
7793
"""解除官方限制"""
@@ -135,8 +151,6 @@ def login_by_cookie(self, cookie: dict) -> int:
135151

136152
def logout(self) -> int:
137153
"""注销"""
138-
self._cookies = None
139-
self._session.cookies.clear()
140154
html = self._get(self._account_url, params={'action': 'logout'})
141155
if not html:
142156
return LanZouCloud.NETWORK_ERROR
@@ -435,6 +449,16 @@ def get_file_info_by_url(self, share_url, pwd='') -> FileDetail:
435449
if not first_page:
436450
return FileDetail(LanZouCloud.NETWORK_ERROR, pwd=pwd, url=share_url)
437451

452+
if "acw_sc__v2" in first_page.text:
453+
# 在页面被过多访问或其他情况下,有时候会先返回一个加密的页面,其执行计算出一个acw_sc__v2后放入页面后再重新访问页面才能获得正常页面
454+
# 若该页面进行了js加密,则进行解密,计算acw_sc__v2,并加入cookie
455+
acw_sc__v2 = calc_acw_sc__v2(first_page.text)
456+
self._session.cookies.set("acw_sc__v2", acw_sc__v2)
457+
logger.debug(f"Set Cookie: acw_sc__v2={acw_sc__v2}")
458+
first_page = self._get(share_url) # 文件分享页面(第一页)
459+
if not first_page:
460+
return FileDetail(LanZouCloud.NETWORK_ERROR, pwd=pwd, url=share_url)
461+
438462
first_page = remove_notes(first_page.text) # 去除网页里的注释
439463
if '文件取消' in first_page or '文件不存在' in first_page:
440464
return FileDetail(LanZouCloud.FILE_CANCELLED, pwd=pwd, url=share_url)
@@ -950,25 +974,8 @@ def down_file_by_url(self, share_url, pwd='', save_path='./Download', *, callbac
950974
if not resp:
951975
return LanZouCloud.FAILED
952976

953-
content_length = resp.headers.get('Content-Length', None)
954-
# 如果无法获取 Content-Length, 先读取一点数据, 再尝试获取一次
955-
# 通常只需读取 1 字节数据
956-
data_iter = resp.iter_content(chunk_size=1)
957-
while not content_length:
958-
logger.warning("Not found Content-Length in response headers")
959-
logger.debug("Read 1 byte from stream...")
960-
try:
961-
next(data_iter)
962-
except StopIteration:
963-
logger.debug("Please wait for a moment before downloading")
964-
return LanZouCloud.FAILED
965-
resp_ = self._get(info.durl, stream=True)
966-
if not resp_:
967-
return LanZouCloud.FAILED
968-
content_length = resp_.headers.get('Content-Length', None)
969-
logger.debug(f"Content-Length: {content_length}")
970-
971-
total_size = int(content_length)
977+
# 如果本地存在同名文件且设置了 overwrite, 则覆盖原文件
978+
# 否则修改下载文件路径, 自动在文件名后加序号
972979
file_path = save_path + os.sep + info.name
973980
if os.path.exists(file_path):
974981
if overwrite:
@@ -981,9 +988,33 @@ def down_file_by_url(self, share_url, pwd='', save_path='./Download', *, callbac
981988
tmp_file_path = file_path + '.download' # 正在下载中的文件名
982989
logger.debug(f'Save file to {tmp_file_path}')
983990

991+
# 对于 txt 文件, 可能出现没有 Content-Length 的情况
992+
# 此时文件需要下载一次才会出现 Content-Length
993+
# 这时候我们先读取一点数据, 再尝试获取一次, 通常只需读取 1 字节数据
994+
content_length = resp.headers.get('Content-Length', None)
995+
if not content_length:
996+
data_iter = resp.iter_content(chunk_size=1)
997+
max_retries = 5 # 5 次拿不到就算了
998+
while not content_length and max_retries > 0:
999+
max_retries -= 1
1000+
logger.warning("Not found Content-Length in response headers")
1001+
logger.debug("Read 1 byte from stream...")
1002+
try:
1003+
next(data_iter) # 读取一个字节
1004+
except StopIteration:
1005+
logger.debug("Please wait for a moment before downloading")
1006+
return LanZouCloud.FAILED
1007+
resp_ = self._get(info.durl, stream=True) # 再请求一次试试
1008+
if not resp_:
1009+
return LanZouCloud.FAILED
1010+
content_length = resp_.headers.get('Content-Length', None)
1011+
logger.debug(f"Content-Length: {content_length}")
1012+
1013+
if not content_length:
1014+
return LanZouCloud.FAILED # 应该不会出现这种情况
1015+
1016+
# 支持断点续传下载
9841017
now_size = 0
985-
chunk_size = 4096
986-
last_512_bytes = b'' # 用于识别文件是否携带真实文件名信息
9871018
if os.path.exists(tmp_file_path):
9881019
now_size = os.path.getsize(tmp_file_path) # 本地已经下载的文件大小
9891020
headers = {**self._headers, 'Range': 'bytes=%d-' % now_size}
@@ -996,30 +1027,43 @@ def down_file_by_url(self, share_url, pwd='', save_path='./Download', *, callbac
9961027

9971028
with open(tmp_file_path, "ab") as f:
9981029
file_name = os.path.basename(file_path)
999-
for chunk in resp.iter_content(chunk_size):
1030+
for chunk in resp.iter_content(4096):
10001031
if chunk:
10011032
f.write(chunk)
10021033
f.flush()
10031034
now_size += len(chunk)
1004-
if total_size - now_size < 512:
1005-
last_512_bytes += chunk
10061035
if callback is not None:
1007-
callback(file_name, total_size, now_size)
1036+
callback(file_name, int(content_length), now_size)
1037+
1038+
# 文件下载完成后, 检查文件尾部 512 字节数据
1039+
# 绕过官方限制上传时, API 会隐藏文件真实信息到文件尾部
1040+
# 这里尝试提取隐藏信息, 并截断文件尾部数据
10081041
os.rename(tmp_file_path, file_path) # 下载完成,改回正常文件名
1009-
# 尝试解析文件报尾
1010-
file_info = un_serialize(last_512_bytes[-512:])
1011-
if file_info is not None and 'padding' in file_info: # 大文件的记录文件也可以反序列化出 name,但是没有 padding
1012-
real_name = file_info['name'] # 解除伪装的真实文件名
1013-
logger.debug(f"Find meta info: real_name={real_name}")
1014-
real_path = save_path + os.sep + real_name
1015-
if overwrite and os.path.exists(real_path):
1016-
os.remove(real_path) # 删除原文件
1017-
new_file_path = auto_rename(real_path)
1018-
os.rename(file_path, new_file_path)
1019-
with open(new_file_path, 'rb+') as f:
1020-
f.seek(-512, 2) # 截断最后 512 字节数据
1021-
f.truncate()
1022-
file_path = new_file_path # 保存文件重命名后真实路径
1042+
if os.path.getsize(file_path) > 512: # 文件大于 512 bytes 就检查一下
1043+
file_info = None
1044+
with open(file_path, 'rb') as f:
1045+
f.seek(-512, os.SEEK_END)
1046+
last_512_bytes = f.read()
1047+
file_info = un_serialize(last_512_bytes)
1048+
1049+
# 大文件的记录文件也可以反序列化出 name,但是没有 padding 字段
1050+
if file_info is not None and 'padding' in file_info:
1051+
real_name = file_info['name'] # 解除伪装的真实文件名
1052+
logger.debug(f"Find meta info: real_name={real_name}")
1053+
real_path = save_path + os.sep + real_name
1054+
# 如果存在同名文件且设置了 overwrite, 删掉原文件
1055+
if overwrite and os.path.exists(real_path):
1056+
os.remove(real_path)
1057+
# 自动重命名, 文件存在就会加个序号
1058+
new_file_path = auto_rename(real_path)
1059+
os.rename(file_path, new_file_path)
1060+
# 截断最后 512 字节隐藏信息, 还原文件
1061+
with open(new_file_path, 'rb+') as f:
1062+
f.seek(-512, os.SEEK_END)
1063+
f.truncate()
1064+
file_path = new_file_path # 保存文件重命名后真实路径
1065+
1066+
# 如果设置了下载完成的回调函数, 调用之
10231067
if downloaded_handler is not None:
10241068
downloaded_handler(os.path.abspath(file_path))
10251069
return LanZouCloud.SUCCESS
@@ -1046,6 +1090,15 @@ def get_folder_info_by_url(self, share_url, dir_pwd='') -> FolderDetail:
10461090
# 要求输入密码, 用户描述中可能带有"输入密码",所以不用这个字符串判断
10471091
if ('id="pwdload"' in html or 'id="passwddiv"' in html) and len(dir_pwd) == 0:
10481092
return FolderDetail(LanZouCloud.LACK_PASSWORD)
1093+
1094+
if "acw_sc__v2" in html:
1095+
# 在页面被过多访问或其他情况下,有时候会先返回一个加密的页面,其执行计算出一个acw_sc__v2后放入页面后再重新访问页面才能获得正常页面
1096+
# 若该页面进行了js加密,则进行解密,计算acw_sc__v2,并加入cookie
1097+
acw_sc__v2 = calc_acw_sc__v2(html)
1098+
self._session.cookies.set("acw_sc__v2", acw_sc__v2)
1099+
logger.debug(f"Set Cookie: acw_sc__v2={acw_sc__v2}")
1100+
html = self._get(share_url).text # 文件分享页面(第一页)
1101+
10491102
try:
10501103
# 获取文件需要的参数
10511104
html = remove_notes(html)
@@ -1136,6 +1189,7 @@ def _check_big_file(self, file_list):
11361189
logger.debug(f"Big file checking: Failed")
11371190
return None
11381191
resp = self._get(info.durl)
1192+
# 这里无需知道 txt 文件的 Content-Length, 全部读取即可
11391193
info = un_serialize(resp.content) if resp else None
11401194
if info is not None: # 确认是大文件
11411195
name, size, *_, parts = info.values() # 真实文件名, 文件字节大小, (其它数据),分段数据文件名(有序)

lanzou/api/utils.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import requests
1313

1414
__all__ = ['logger', 'remove_notes', 'name_format', 'time_format', 'is_name_valid', 'is_file_url',
15-
'is_folder_url', 'big_file_split', 'un_serialize', 'let_me_upload', 'auto_rename']
15+
'is_folder_url', 'big_file_split', 'un_serialize', 'let_me_upload', 'auto_rename', 'calc_acw_sc__v2']
1616

1717
# 调试日志设置
1818
logger = logging.getLogger('lanzou')
@@ -26,7 +26,7 @@
2626

2727
headers = {
2828
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36',
29-
'Referer': 'https://pan.lanzous.com',
29+
# 'Referer': 'https://pan.lanzous.com', # 可以没有
3030
'Accept-Language': 'zh-CN,zh;q=0.9',
3131
}
3232

@@ -207,3 +207,38 @@ def auto_rename(file_path) -> str:
207207
while f"{fname_no_ext}({count}){ext}" in flist:
208208
count += 1
209209
return fpath + os.sep + fname_no_ext + '(' + str(count) + ')' + ext
210+
211+
212+
def calc_acw_sc__v2(html_text: str) -> str:
213+
arg1 = re.search(r"arg1='([0-9A-Z]+)'", html_text)
214+
arg1 = arg1.group(1) if arg1 else ""
215+
acw_sc__v2 = hex_xor(unsbox(arg1), "3000176000856006061501533003690027800375")
216+
return acw_sc__v2
217+
218+
219+
# 参考自 https://zhuanlan.zhihu.com/p/228507547
220+
def unsbox(str_arg):
221+
v1 = [15, 35, 29, 24, 33, 16, 1, 38, 10, 9, 19, 31, 40, 27, 22, 23, 25, 13, 6, 11, 39, 18, 20, 8, 14, 21, 32, 26, 2,
222+
30, 7, 4, 17, 5, 3, 28, 34, 37, 12, 36]
223+
v2 = ["" for _ in v1]
224+
for idx in range(0, len(str_arg)):
225+
v3 = str_arg[idx]
226+
for idx2 in range(len(v1)):
227+
if v1[idx2] == idx + 1:
228+
v2[idx2] = v3
229+
230+
res = ''.join(v2)
231+
return res
232+
233+
234+
def hex_xor(str_arg, args):
235+
res = ''
236+
for idx in range(0, min(len(str_arg), len(args)), 2):
237+
v1 = int(str_arg[idx:idx + 2], 16)
238+
v2 = int(args[idx:idx + 2], 16)
239+
v3 = format(v1 ^ v2, 'x')
240+
if len(v3) == 1:
241+
v3 = '0' + v3
242+
res += v3
243+
244+
return res

lanzou/cmder/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from lanzou.cmder.config import config
22

3-
version = '2.6.3'
3+
version = '2.6.5'
44

55
__all__ = ['cmder', 'utils', 'version', 'config']

0 commit comments

Comments
 (0)