发布 1.9 版本
44
README.md
@@ -13,7 +13,6 @@
|
||||
</div>
|
||||
<br>
|
||||
<p>🔥 <b>小红书链接提取/作品采集工具</b>:提取账号发布、收藏、点赞作品链接;提取搜索结果作品、用户链接;采集小红书作品信息;提取小红书作品下载地址;下载小红书无水印作品文件!</p>
|
||||
<p>❤️ 作者仅在 GitHub 发布 XHS-Downloader,未与任何个人或网站合作发布,项目没有任何收费计划,谨防上当受骗!</p>
|
||||
<h1>📑 项目功能</h1>
|
||||
<ul><b>程序功能</b>
|
||||
<li>✅ 采集小红书作品信息</li>
|
||||
@@ -27,6 +26,7 @@
|
||||
<li>✅ 后台监听剪贴板下载作品</li>
|
||||
<li>✅ 记录已下载作品 ID</li>
|
||||
<li>✅ 支持命令行下载作品文件</li>
|
||||
<li>✅ 从浏览器读取 Cookie</li>
|
||||
<li>☑️ 支持 API 调用功能</li>
|
||||
</ul>
|
||||
<ul><b>脚本功能</b>
|
||||
@@ -57,8 +57,9 @@
|
||||
<p>⭐ 推荐使用 <a href="https://learn.microsoft.com/zh-cn/windows/terminal/install">Windows 终端</a> (Windows 11 默认终端)运行程序以便获得最佳显示效果!</p>
|
||||
<h1>🥣 使用方法</h1>
|
||||
<p>如果仅需下载无水印作品文件,建议选择 <b>程序运行</b>;如果有其他需求,建议选择 <b>源码运行</b>!</p>
|
||||
<p>建议自行设置 <code>cookie</code> 参数,若不设置该参数,程序功能可能无法正常使用!</p>
|
||||
<h2>🖱 程序运行</h2>
|
||||
<p>Windows 10 及以上用户可前往 <a href="https://github.com/JoeanAmier/XHS-Downloader/releases/latest">Releases</a> 下载程序压缩包,解压后打开程序文件夹,双击运行 <code>main.exe</code> 即可使用。</p>
|
||||
<p>Windows 10 及以上用户可前往 <a href="https://github.com/JoeanAmier/XHS-Downloader/releases/latest">Releases</a> 下载程序压缩包或安装包,解压或安装后打开程序文件夹,双击运行 <code>main.exe</code> 即可使用。</p>
|
||||
<p>若通过此方式使用程序,文件默认下载路径为:<code>.\_internal\Download</code>;配置文件路径为:<code>.\_internal\settings.json</code></p>
|
||||
<h2>⌨️ 源码运行</h2>
|
||||
<ol>
|
||||
@@ -69,6 +70,10 @@
|
||||
</ol>
|
||||
<h1>🛠 命令行模式</h1>
|
||||
<p>项目支持命令行运行模式,若想要下载图文作品的部分图片,可以使用此模式传入需要下载的图片序号!</p>
|
||||
<p>可以使用命令行从浏览器读取 Cookie 并写入配置文件!注意需要关闭对应浏览器才能读取数据!</p>
|
||||
<p><code>bool</code> 类型参数支持使用 <code>true</code>、<code>false</code>、<code>1</code>、<code>0</code>、<code>yes</code>、<code>no</code>、<code>on</code> 或 <code>off</code>(不区分大小写)来设置。</p>
|
||||
<p>命令示例:<code>python .\main.py --browser_cookie Chrome --update_settings</code></p>
|
||||
<hr>
|
||||
<img src="static/screenshot/命令行模式截图1.png" alt="">
|
||||
<hr>
|
||||
<img src="static/screenshot/命令行模式截图2.png" alt="">
|
||||
@@ -96,7 +101,7 @@ async def example():
|
||||
timeout = 5 # 请求数据超时限制,单位:秒,默认值:10
|
||||
chunk = 1024 * 1024 * 10 # 下载文件时,每次从服务器获取的数据块大小,单位:字节
|
||||
max_retry = 2 # 请求数据失败时,重试的最大次数,单位:秒,默认值:5
|
||||
record_data = False # 是否记录作品数据至文件
|
||||
record_data = False # 是否保存作品数据至文件
|
||||
image_format = "WEBP" # 图文作品文件下载格式,支持:PNG、WEBP
|
||||
folder_mode = False # 是否将每个作品的文件储存至单独的文件夹
|
||||
async with XHS() as xhs:
|
||||
@@ -114,17 +119,15 @@ async def example():
|
||||
folder_mode=folder_mode,
|
||||
) as xhs: # 使用自定义参数
|
||||
download = True # 是否下载作品文件,默认值:False
|
||||
efficient = True # 高效模式,禁用请求延时
|
||||
# 返回作品详细信息,包括下载地址
|
||||
# 获取数据失败时返回空字典
|
||||
print(await xhs.extract(error_link, download, efficient=efficient))
|
||||
print(await xhs.extract(demo_link, download, efficient=efficient))
|
||||
print(await xhs.extract(error_link, download, ))
|
||||
print(await xhs.extract(demo_link, download, ))
|
||||
# 支持传入多个作品链接
|
||||
print(await xhs.extract(multiple_links, download, efficient=efficient))
|
||||
print(await xhs.extract(multiple_links, download, ))
|
||||
</pre>
|
||||
<h1>⚙️ 配置文件</h1>
|
||||
<p>项目根目录下的 <code>settings.json</code> 文件,首次运行自动生成,可以自定义部分运行参数。</p>
|
||||
<p>建议自行设置 <code>cookie</code> 参数,若不设置该参数,程序功能可能无法正常使用!</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -186,7 +189,7 @@ async def example():
|
||||
<tr>
|
||||
<td align="center">record_data</td>
|
||||
<td align="center">bool</td>
|
||||
<td align="center">是否记录作品数据至 <code>TXT</code> 文件</td>
|
||||
<td align="center">是否保存作品数据至文件,保存格式:<code>SQLite</code></td>
|
||||
<td align="center">false</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -204,8 +207,8 @@ async def example():
|
||||
<tr>
|
||||
<td align="center">language</td>
|
||||
<td align="center">str</td>
|
||||
<td align="center">设置程序语言,目前支持:<code>zh-CN</code>、<code>en-GB</code></td>
|
||||
<td align="center">zh-CN</td>
|
||||
<td align="center">设置程序语言,目前支持:<code>zh_CN</code>、<code>en_GB</code></td>
|
||||
<td align="center">zh_CN</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -214,11 +217,12 @@ async def example():
|
||||
<li>打开浏览器(可选无痕模式启动),访问 <code>https://www.xiaohongshu.com/explore</code></li>
|
||||
<li>按下 <code>F12</code> 打开开发人员工具</li>
|
||||
<li>选择 <code>网络</code> 选项卡</li>
|
||||
<li>勾选 <code>保留日志</code></li>
|
||||
<li>在 <code>过滤</code> 输入框输入 <code>cookie-name:web_session</code></li>
|
||||
<li>选择 <code>Fetch/XHR</code> 筛选器</li>
|
||||
<li>点击小红书页面任意作品</li>
|
||||
<li>在 <code>网络</code> 选项卡挑选包含 Cookie 的数据包</li>
|
||||
<li>检查 Cookie 是否包含 <code>web_session</code> 字段</li>
|
||||
<li>全选复制包含 <code>web_session</code> 字段的 Cookie</li>
|
||||
<li>在 <code>网络</code> 选项卡选择任意数据包(如果无数据包,重复步骤7)</li>
|
||||
<li>全选复制 Cookie 写入程序或配置文件</li>
|
||||
</ol>
|
||||
<br>
|
||||
<img src="static/screenshot/获取Cookie示意图.png" alt="">
|
||||
@@ -242,11 +246,16 @@ async def example():
|
||||
<p>如果您愿意,可以考虑提供资助为 <b>XHS-Downloader</b> 提供额外的支持!</p>
|
||||
<h1>✉️ 联系作者</h1>
|
||||
<ul>
|
||||
<li>微信: Downloader_Tools</li>
|
||||
<li>微信公众号: Downloader Tools</li>
|
||||
<li>微信(其他事务): Downloader_Tools</li>
|
||||
<li>微信公众号(问题解答): Downloader Tools</li>
|
||||
<li>QQ 群聊(使用交流): <a href="https://github.com/JoeanAmier/XHS-Downloader/blob/master/static/QQ%E7%BE%A4%E8%81%8A%E4%BA%8C%E7%BB%B4%E7%A0%81.png">扫码加入群聊</a></li>
|
||||
</ul>
|
||||
<p><b>如果您对抖音 / TikTok 感兴趣,可以了解一下我的另一个开源项目 <a href="https://github.com/JoeanAmier/TikTokDownloader">TikTokDownloader</a></b></p>
|
||||
<p><b>说明:</b>QQ 群聊仅限于讨论项目使用问题,严禁发布任何广告,严禁讨论任何账号交易、账号流量、流量变现、灰色产业等相关的内容!</p>
|
||||
<p>✨ <b>作者的其他开源项目:</b></p>
|
||||
<ul>
|
||||
<li><b>TikTokDownloader(抖音 / TikTok)</b>:<a href="https://github.com/JoeanAmier/TikTokDownloader">https://github.com/JoeanAmier/TikTokDownloader</a></li>
|
||||
<li><b>KS-Downloader(快手)</b>:<a href="https://github.com/JoeanAmier/KS-Downloader">https://github.com/JoeanAmier/KS-Downloader</a></li>
|
||||
</ul>
|
||||
<h1>⚠️ 免责声明</h1>
|
||||
<ul>
|
||||
<li>使用者对本项目的使用由使用者自行决定,并自行承担风险。作者对使用者使用本项目所产生的任何损失、责任、或风险概不负责。</li>
|
||||
@@ -269,3 +278,4 @@ async def example():
|
||||
* https://textual.textualize.io/
|
||||
* https://aiosqlite.omnilib.dev/en/stable/
|
||||
* https://click.palletsprojects.com/en/8.1.x/
|
||||
* https://github.com/borisbabic/browser_cookie3
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<div align="center">
|
||||
<p>🔥 <b>Xiaohongshu Artwork Collection Tool</b>: Collect information on Xiaohongshu artworks; Extract the download address of Xiaohongshu artworks; Download the Xiaohongshu watermark-free artwork files!</p>
|
||||
<p>❤️ The author only releases XHS-Downloader on GitHub, without collaborating with any individuals or websites. Additionally, there are no charging plans for the tool!</p>
|
||||
<p>⭐ Due to the author's limited energy, I was unable to update the English document in a timely manner, and the content may have become outdated. Suggest referring to Chinese documentation. If you want to contribute to translation, we warmly welcome you</p>
|
||||
</div>
|
||||
<h1>📑 The Project Features:</h1>
|
||||
<ul>
|
||||
@@ -195,8 +196,8 @@ async with XHS(work_path=work_path,
|
||||
<tr>
|
||||
<td align="center">language</td>
|
||||
<td align="center">str</td>
|
||||
<td align="center">Set programming language, currently support: <code>zh-CN</code>, <code>en-GB</code></td>
|
||||
<td align="center">zh-CN</td>
|
||||
<td align="center">Set programming language, currently support: <code>zh_CN</code>, <code>en_GB</code></td>
|
||||
<td align="center">zh_CN</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
5
locale/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 贡献指南
|
||||
|
||||
* 如果想要贡献支持更多语言,请参考 `zh_CN` 与 `en_GB` 的文件夹层级结构,复制 `locale/zh_CN/LC_MESSAGES/xhs.po` 文件并编辑翻译。
|
||||
* 如果想要贡献改进翻译结果,请直接编辑 `xhs.po` 文件内容。
|
||||
* 不需要提交 `xhs.mo` 文件,提交 `xhs.po` 文件后,作者会转换格式并合并。
|
||||
BIN
locale/en_GB/LC_MESSAGES/xhs.mo
Normal file
233
locale/en_GB/LC_MESSAGES/xhs.po
Normal file
@@ -0,0 +1,233 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Language: en_GB\n"
|
||||
|
||||
msgid "免责声明\n"
|
||||
msgstr "Disclaimer about XHS-Downloader:\n"
|
||||
"\n"
|
||||
"1. The user decides on their own use of this project and assumes all risks. The author is not responsible for any losses, liabilities, or risks incurred by the user in using this project.\n"
|
||||
"2. The code and features provided by the author of this project are developed based on existing knowledge and technology. The author strives to ensure the correctness and security of the code but does not guarantee that the code is entirely free of errors or defects.\n"
|
||||
"3. The user must strictly adhere to the requirements of the GNU General Public License v3.0 when using this project and appropriately acknowledge the use of code licensed under the GNU General Public License v3.0.\n"
|
||||
"4. Under no circumstances may the user associate the author, contributors, or other relevant parties of this project with the user's actions, nor demand them to be held responsible for any losses or damages incurred by the user in using this project.\n"
|
||||
"5. The user must independently research relevant laws and regulations when using the code and features of this project, ensuring that their use is legal and compliant. Any legal responsibilities and risks arising from violations of laws and regulations are the sole responsibility of the user.\n"
|
||||
"6. The author of this project will not offer a paid version of the XHS-Downloader project and will not provide any commercial services related to the XHS-Downloader project.\n"
|
||||
"7. Any secondary development, modification, or compilation of programs based on this project is not associated with the original author. The original author is not responsible for any consequences related to secondary development actions or their results. The user is solely responsible for all situations that may arise from secondary development.\n"
|
||||
"\n"
|
||||
"Before using the code and features of this project, please carefully consider and accept the above disclaimers. If you have any questions or do not agree with the statements above, please refrain from using the code and features of this project. If you proceed to use the code and features of this project, it will be considered that you fully understand and accept the disclaimers mentioned above, and willingly assume all risks and consequences associated with using this project.\n"
|
||||
|
||||
msgid "提取作品文件下载地址失败"
|
||||
msgstr "Failed to extract the download address for the Xiaohongshu works files"
|
||||
|
||||
msgid "提取小红书作品链接失败"
|
||||
msgstr "Failed to extract the links for Xiaohongshu works"
|
||||
|
||||
msgid "未输入任何小红书作品链接"
|
||||
msgstr "No Xiaohongshu works links provided"
|
||||
|
||||
msgid "下载小红书作品文件失败"
|
||||
msgstr "Failed to download the Xiaohongshu works files"
|
||||
|
||||
msgid "正在检查新版本,请稍等..."
|
||||
msgstr "Checking for new version, please wait..."
|
||||
|
||||
msgid "当前版本为开发版, 可更新至正式版"
|
||||
msgstr "The current version is a development version, and can be updated to the official version"
|
||||
|
||||
msgid "当前已是最新开发版"
|
||||
msgstr "You are already using the latest development version"
|
||||
|
||||
msgid "当前已是最新正式版"
|
||||
msgstr "You are already using the latest official version"
|
||||
|
||||
msgid "检测新版本失败"
|
||||
msgstr "Failed to check for a new version"
|
||||
|
||||
msgid "开源协议"
|
||||
msgstr "Open Source License"
|
||||
|
||||
msgid "项目地址"
|
||||
msgstr "Project Address"
|
||||
|
||||
msgid "请输入小红书图文/视频作品链接"
|
||||
msgstr "Please enter the link to the Xiaohongshu image/text or video works"
|
||||
|
||||
msgid "多个链接之间使用空格分隔"
|
||||
msgstr "Separate multiple links with spaces"
|
||||
|
||||
msgid "下载无水印作品文件"
|
||||
msgstr "Download images/video files"
|
||||
|
||||
msgid "读取剪贴板"
|
||||
msgstr "Read the clipboard"
|
||||
|
||||
msgid "清空输入框"
|
||||
msgstr "Clear the input box"
|
||||
|
||||
msgid "退出程序"
|
||||
msgstr "Exit the program"
|
||||
|
||||
msgid "检查更新"
|
||||
msgstr "Check for updates"
|
||||
|
||||
msgid "程序设置"
|
||||
msgstr "Settings"
|
||||
|
||||
msgid "作品数据 / 文件保存根路径"
|
||||
msgstr "Root path for saving works data / files"
|
||||
|
||||
msgid "作品文件储存文件夹名称"
|
||||
msgstr "Name of the folder for storing works files"
|
||||
|
||||
msgid "小红书网页版 Cookie"
|
||||
msgstr "Xiaohongshu Web Cookie"
|
||||
|
||||
msgid "网络代理"
|
||||
msgstr "Network proxy"
|
||||
|
||||
msgid "记录作品数据"
|
||||
msgstr "Record works data"
|
||||
|
||||
msgid "图片下载格式"
|
||||
msgstr "Image download format"
|
||||
|
||||
msgid "作品文件夹归档模式"
|
||||
msgstr "Folder archiving mode"
|
||||
|
||||
msgid "程序语言"
|
||||
msgstr "Program language"
|
||||
|
||||
msgid "启动本地服务器"
|
||||
msgstr "Start local server"
|
||||
|
||||
msgid "程序根路径"
|
||||
msgstr "Program root path"
|
||||
|
||||
msgid "小红书网页版 Cookie,无需登录,参数已设置"
|
||||
msgstr "Xiaohongshu web version cookie, no login required, parameters have been set"
|
||||
|
||||
msgid "小红书网页版 Cookie,无需登录,参数未设置"
|
||||
msgstr "Xiaohongshu web version cookie, no login required, parameters not set"
|
||||
|
||||
msgid "默认 User-Agent"
|
||||
msgstr "Default User-Agent"
|
||||
|
||||
msgid "不使用代理"
|
||||
msgstr "No proxy"
|
||||
|
||||
msgid "保存配置"
|
||||
msgstr "Save configuration"
|
||||
|
||||
msgid "放弃更改"
|
||||
msgstr "Discard changes"
|
||||
|
||||
msgid "程序处理中..."
|
||||
msgstr "Processing..."
|
||||
|
||||
msgid "已启动监听剪贴板模式"
|
||||
msgstr "Currently in monitoring clipboard mode"
|
||||
|
||||
msgid "程序会自动读取并提取剪贴板中的小红书作品链接,并自动下载链接对应的作品文件,如需关闭,请点击关闭按钮,或者向剪贴板写入 “close” 文本!"
|
||||
msgstr "The program will automatically read and extract the link to Xiaohongshu's works from the clipboard, and automatically download the corresponding work file. If you want to close it, please click the close button or write the \"close\" text to the clipboard!"
|
||||
|
||||
msgid "退出监听剪贴板模式"
|
||||
msgstr "Exit monitoring clipboard mode"
|
||||
|
||||
msgid "请输入待删除的小红书作品链接或作品 ID"
|
||||
msgstr "Please enter the link or ID of the Xiaohongshu works to be deleted"
|
||||
|
||||
msgid "支持输入作品 ID 或包含作品 ID 的作品链接,多个链接或 ID 之间使用空格分隔"
|
||||
msgstr "Support input of works ID or links containing works ID, with multiple links or IDs separated by spaces"
|
||||
|
||||
msgid "删除指定作品 ID"
|
||||
msgstr "Delete specified works ID"
|
||||
|
||||
msgid "返回首页"
|
||||
msgstr "return"
|
||||
|
||||
msgid "小红书作品链接"
|
||||
msgstr "Link to Xiaohongshu works"
|
||||
|
||||
msgid "下载指定序号的图片文件,仅对图文作品生效;多个序号输入示例:\"1 3 5 7\""
|
||||
msgstr "Download image files with specified serial numbers, only effective for image works; Example of multiple serial numbers input: \"1 3 5 7\""
|
||||
|
||||
msgid "小红书网页版 Cookie,无需登录"
|
||||
msgstr "Xiaohongshu web version cookie, no need to log in"
|
||||
|
||||
msgid "请求数据超时限制,单位:秒"
|
||||
msgstr "Network request timeout limit, in seconds"
|
||||
|
||||
msgid "下载文件时,每次从服务器获取的数据块大小,单位:字节"
|
||||
msgstr "When downloading a file, the size of the data block obtained from the server each time, in bytes"
|
||||
|
||||
msgid "请求数据失败时,重试的最大次数"
|
||||
msgstr "The maximum number of retries when data request fails"
|
||||
|
||||
msgid "是否记录作品数据至文件"
|
||||
msgstr "Record works data to file"
|
||||
|
||||
msgid "图文作品文件下载格式,支持:PNG、WEBP"
|
||||
msgstr "Image works file download format, supporting: PNG, WEBP"
|
||||
|
||||
msgid "是否将每个作品的文件储存至单独的文件夹"
|
||||
msgstr "Do you need to store the files of each works in a separate folder"
|
||||
|
||||
msgid "设置程序语言,目前支持:zh_CN、en_GB"
|
||||
msgstr "Set program language, currently supports: zh_CN, en_GB"
|
||||
|
||||
msgid "读取指定配置文件"
|
||||
msgstr "Read specified configuration file"
|
||||
|
||||
msgid "是否更新配置文件"
|
||||
msgstr "Do you need to update the configuration file"
|
||||
|
||||
msgid "查看详细参数说明"
|
||||
msgstr "View detailed parameter descriptions"
|
||||
|
||||
msgid "网络异常,请求 {0} 失败"
|
||||
msgstr "Network error, failed to access {0}"
|
||||
|
||||
msgid "{0} 文件已存在,跳过下载"
|
||||
msgstr "{0} already exists, skipping download"
|
||||
|
||||
msgid "文件 {0} 下载成功"
|
||||
msgstr "file {0} download successful"
|
||||
|
||||
msgid "网络异常,{0} 下载失败"
|
||||
msgstr "Network error, {0} download failed"
|
||||
|
||||
msgid "共 {0} 个小红书作品待处理..."
|
||||
msgstr "{0} works from Xiaohongshu are awaiting processing..."
|
||||
|
||||
msgid "开始处理作品:{0}"
|
||||
msgstr "Start processing the works: {0}"
|
||||
|
||||
msgid "{0} 获取数据失败"
|
||||
msgstr "{0} failed to retrieve data"
|
||||
|
||||
msgid "{0} 提取数据失败"
|
||||
msgstr "{0} failed to extract data"
|
||||
|
||||
msgid "作品处理完成:{0}"
|
||||
msgstr "works processing completed: {0}"
|
||||
|
||||
msgid "检测到新版本:{0}.{1}"
|
||||
msgstr "New version detected: {0}.{1}"
|
||||
|
||||
msgid "作品 {0} 存在下载记录,跳过下载"
|
||||
msgstr "works {0} has a download record, skipping download"
|
||||
|
||||
msgid "从指定的浏览器读取小红书网页版 Cookie,需要关闭对应的浏览器,支持:1 Chrome, 2 Chromium, 3 Opera, 4 Opera GX, 5 Brave, 6 Edge, 7 Vivaldi, 8 Firefox, 9 LibreWolf, 10 Safari,输入浏览器类型或序号"
|
||||
msgstr "To read the Xiaohongshu web version cookie from the specified browser, the corresponding browser needs to be closed. Supports: 1 Chrome, 2 Chromium, 3 Opera, 4 Opera GX, 5 Brave, 6 Edge, 7 Vivaldi, 8 Firefox, 9 LibreWolf, 10 Safari, enter the browser type or serial number"
|
||||
|
||||
msgid "查看 XHS-Downloader 版本"
|
||||
msgstr "View XHS Downloader Version"
|
||||
|
||||
msgid "如果 XHS-Downloader 对您有帮助,请考虑为它点个 Star,感谢您的支持!"
|
||||
msgstr "If XHS-Downloader is helpful to you, please consider giving it Star. Thank you for your support!"
|
||||
|
||||
msgid "作者的其他开源项目"
|
||||
msgstr "Other open-source projects of the author"
|
||||
|
||||
msgid "文件 {0} 请求失败,响应码 {1}"
|
||||
msgstr "File {0} request failed with response code {1}"
|
||||
27
locale/po_to_mo.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from pathlib import Path
|
||||
from subprocess import run
|
||||
|
||||
__all__ = []
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def scan_directory():
|
||||
return [
|
||||
item.joinpath("LC_MESSAGES/xhs.po")
|
||||
for item in ROOT.iterdir()
|
||||
if item.is_dir()
|
||||
]
|
||||
|
||||
|
||||
def generate_map(files: list[Path]):
|
||||
return [(i, i.with_suffix(".mo")) for i in files]
|
||||
|
||||
|
||||
def generate_mo(maps: list[tuple[Path, Path]]):
|
||||
for i, j in maps:
|
||||
command = f"msgfmt \"{i}\" -o \"{j}\""
|
||||
print(run(command, shell=True, text=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_mo(generate_map(scan_directory()))
|
||||
BIN
locale/zh_CN/LC_MESSAGES/xhs.mo
Normal file
233
locale/zh_CN/LC_MESSAGES/xhs.po
Normal file
@@ -0,0 +1,233 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Language: zh_CN\n"
|
||||
|
||||
msgid "免责声明\n"
|
||||
msgstr "关于 XHS-Downloader 的 免责声明:\n"
|
||||
"\n"
|
||||
"1.使用者对本项目的使用由使用者自行决定,并自行承担风险。作者对使用者使用本项目所产生的任何损失、责任、或风险概不负责。\n"
|
||||
"2.本项目的作者提供的代码和功能是基于现有知识和技术的开发成果。作者尽力确保代码的正确性和安全性,但不保证代码完全没有错误或缺陷。\n"
|
||||
"3.使用者在使用本项目时必须严格遵守 GNU General Public License v3.0 的要求,并在适当的地方注明使用了 GNU General Public License v3.0 的代码。\n"
|
||||
"4.使用者在任何情况下均不得将本项目的作者、贡献者或其他相关方与使用者的使用行为联系起来,或要求其对使用者使用本项目所产生的任何损失或损害负责。\n"
|
||||
"5.使用者在使用本项目的代码和功能时,必须自行研究相关法律法规,并确保其使用行为合法合规。任何因违反法律法规而导致的法律责任和风险,均由使用者自行承担。\n"
|
||||
"6.本项目的作者不会提供 XHS-Downloader 项目的付费版本,也不会提供与 XHS-Downloader 项目相关的任何商业服务。\n"
|
||||
"7.基于本项目进行的任何二次开发、修改或编译的程序与原创作者无关,原创作者不承担与二次开发行为或其结果相关的任何责任,使用者应自行对因二次开发可能带来的各种情况负全部责任。\n"
|
||||
"\n"
|
||||
"在使用本项目的代码和功能之前,请您认真考虑并接受以上免责声明。如果您对上述声明有任何疑问或不同意,请不要使用本项目的代码和功能。如果您使用了本项目的代码和功能,则视为您已完全理解并接受上述免责声明,并自愿承担使用本项目的一切风险和后果。\n"
|
||||
|
||||
msgid "提取作品文件下载地址失败"
|
||||
msgstr ""
|
||||
|
||||
msgid "提取小红书作品链接失败"
|
||||
msgstr ""
|
||||
|
||||
msgid "未输入任何小红书作品链接"
|
||||
msgstr ""
|
||||
|
||||
msgid "下载小红书作品文件失败"
|
||||
msgstr ""
|
||||
|
||||
msgid "正在检查新版本,请稍等..."
|
||||
msgstr ""
|
||||
|
||||
msgid "当前版本为开发版, 可更新至正式版"
|
||||
msgstr ""
|
||||
|
||||
msgid "当前已是最新开发版"
|
||||
msgstr ""
|
||||
|
||||
msgid "当前已是最新正式版"
|
||||
msgstr ""
|
||||
|
||||
msgid "检测新版本失败"
|
||||
msgstr ""
|
||||
|
||||
msgid "开源协议"
|
||||
msgstr ""
|
||||
|
||||
msgid "项目地址"
|
||||
msgstr ""
|
||||
|
||||
msgid "请输入小红书图文/视频作品链接"
|
||||
msgstr ""
|
||||
|
||||
msgid "多个链接之间使用空格分隔"
|
||||
msgstr ""
|
||||
|
||||
msgid "下载无水印作品文件"
|
||||
msgstr ""
|
||||
|
||||
msgid "读取剪贴板"
|
||||
msgstr ""
|
||||
|
||||
msgid "清空输入框"
|
||||
msgstr ""
|
||||
|
||||
msgid "退出程序"
|
||||
msgstr ""
|
||||
|
||||
msgid "检查更新"
|
||||
msgstr ""
|
||||
|
||||
msgid "程序设置"
|
||||
msgstr ""
|
||||
|
||||
msgid "作品数据 / 文件保存根路径"
|
||||
msgstr ""
|
||||
|
||||
msgid "作品文件储存文件夹名称"
|
||||
msgstr ""
|
||||
|
||||
msgid "小红书网页版 Cookie"
|
||||
msgstr ""
|
||||
|
||||
msgid "网络代理"
|
||||
msgstr ""
|
||||
|
||||
msgid "记录作品数据"
|
||||
msgstr ""
|
||||
|
||||
msgid "图片下载格式"
|
||||
msgstr ""
|
||||
|
||||
msgid "作品文件夹归档模式"
|
||||
msgstr ""
|
||||
|
||||
msgid "程序语言"
|
||||
msgstr ""
|
||||
|
||||
msgid "启动本地服务器"
|
||||
msgstr ""
|
||||
|
||||
msgid "程序根路径"
|
||||
msgstr ""
|
||||
|
||||
msgid "小红书网页版 Cookie,无需登录,参数已设置"
|
||||
msgstr ""
|
||||
|
||||
msgid "小红书网页版 Cookie,无需登录,参数未设置"
|
||||
msgstr ""
|
||||
|
||||
msgid "默认 User-Agent"
|
||||
msgstr ""
|
||||
|
||||
msgid "不使用代理"
|
||||
msgstr ""
|
||||
|
||||
msgid "保存配置"
|
||||
msgstr ""
|
||||
|
||||
msgid "放弃更改"
|
||||
msgstr ""
|
||||
|
||||
msgid "程序处理中..."
|
||||
msgstr ""
|
||||
|
||||
msgid "已启动监听剪贴板模式"
|
||||
msgstr ""
|
||||
|
||||
msgid "程序会自动读取并提取剪贴板中的小红书作品链接,并自动下载链接对应的作品文件,如需关闭,请点击关闭按钮,或者向剪贴板写入 “close” 文本!"
|
||||
msgstr ""
|
||||
|
||||
msgid "退出监听剪贴板模式"
|
||||
msgstr ""
|
||||
|
||||
msgid "请输入待删除的小红书作品链接或作品 ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "支持输入作品 ID 或包含作品 ID 的作品链接,多个链接或 ID 之间使用空格分隔"
|
||||
msgstr ""
|
||||
|
||||
msgid "删除指定作品 ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "返回首页"
|
||||
msgstr ""
|
||||
|
||||
msgid "小红书作品链接"
|
||||
msgstr ""
|
||||
|
||||
msgid "下载指定序号的图片文件,仅对图文作品生效;多个序号输入示例:\"1 3 5 7\""
|
||||
msgstr ""
|
||||
|
||||
msgid "小红书网页版 Cookie,无需登录"
|
||||
msgstr ""
|
||||
|
||||
msgid "请求数据超时限制,单位:秒"
|
||||
msgstr ""
|
||||
|
||||
msgid "下载文件时,每次从服务器获取的数据块大小,单位:字节"
|
||||
msgstr ""
|
||||
|
||||
msgid "请求数据失败时,重试的最大次数"
|
||||
msgstr ""
|
||||
|
||||
msgid "是否记录作品数据至文件"
|
||||
msgstr ""
|
||||
|
||||
msgid "图文作品文件下载格式,支持:PNG、WEBP"
|
||||
msgstr ""
|
||||
|
||||
msgid "是否将每个作品的文件储存至单独的文件夹"
|
||||
msgstr ""
|
||||
|
||||
msgid "设置程序语言,目前支持:zh_CN、en_GB"
|
||||
msgstr ""
|
||||
|
||||
msgid "读取指定配置文件"
|
||||
msgstr ""
|
||||
|
||||
msgid "是否更新配置文件"
|
||||
msgstr ""
|
||||
|
||||
msgid "查看详细参数说明"
|
||||
msgstr ""
|
||||
|
||||
msgid "网络异常,请求 {0} 失败"
|
||||
msgstr ""
|
||||
|
||||
msgid "{0} 文件已存在,跳过下载"
|
||||
msgstr ""
|
||||
|
||||
msgid "文件 {0} 下载成功"
|
||||
msgstr ""
|
||||
|
||||
msgid "网络异常,{0} 下载失败"
|
||||
msgstr ""
|
||||
|
||||
msgid "共 {0} 个小红书作品待处理..."
|
||||
msgstr ""
|
||||
|
||||
msgid "开始处理作品:{0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "{0} 获取数据失败"
|
||||
msgstr ""
|
||||
|
||||
msgid "{0} 提取数据失败"
|
||||
msgstr ""
|
||||
|
||||
msgid "作品处理完成:{0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "检测到新版本:{0}.{1}"
|
||||
msgstr ""
|
||||
|
||||
msgid "作品 {0} 存在下载记录,跳过下载"
|
||||
msgstr ""
|
||||
|
||||
msgid "从指定的浏览器读取小红书网页版 Cookie,需要关闭对应的浏览器,支持:1 Chrome, 2 Chromium, 3 Opera, 4 Opera GX, 5 Brave, 6 Edge, 7 Vivaldi, 8 Firefox, 9 LibreWolf, 10 Safari,输入浏览器类型或序号"
|
||||
msgstr ""
|
||||
|
||||
msgid "查看 XHS-Downloader 版本"
|
||||
msgstr ""
|
||||
|
||||
msgid "如果 XHS-Downloader 对您有帮助,请考虑为它点个 Star,感谢您的支持!"
|
||||
msgstr ""
|
||||
|
||||
msgid "作者的其他开源项目"
|
||||
msgstr ""
|
||||
|
||||
msgid "文件 {0} 请求失败,响应码 {1}"
|
||||
msgstr ""
|
||||
9
main.py
@@ -21,7 +21,7 @@ async def example():
|
||||
timeout = 5 # 请求数据超时限制,单位:秒,默认值:10
|
||||
chunk = 1024 * 1024 * 10 # 下载文件时,每次从服务器获取的数据块大小,单位:字节
|
||||
max_retry = 2 # 请求数据失败时,重试的最大次数,单位:秒,默认值:5
|
||||
record_data = False # 是否记录作品数据至文件
|
||||
record_data = False # 是否保存作品数据至文件
|
||||
image_format = "WEBP" # 图文作品文件下载格式,支持:PNG、WEBP
|
||||
folder_mode = False # 是否将每个作品的文件储存至单独的文件夹
|
||||
async with XHS() as xhs:
|
||||
@@ -39,13 +39,12 @@ async def example():
|
||||
folder_mode=folder_mode,
|
||||
) as xhs: # 使用自定义参数
|
||||
download = True # 是否下载作品文件,默认值:False
|
||||
efficient = True # 高效模式,禁用请求延时
|
||||
# 返回作品详细信息,包括下载地址
|
||||
# 获取数据失败时返回空字典
|
||||
print(await xhs.extract(error_link, download, efficient=efficient))
|
||||
print(await xhs.extract(demo_link, download, efficient=efficient))
|
||||
print(await xhs.extract(error_link, download, ))
|
||||
print(await xhs.extract(demo_link, download, ))
|
||||
# 支持传入多个作品链接
|
||||
print(await xhs.extract(multiple_links, download, efficient=efficient))
|
||||
print(await xhs.extract(multiple_links, download, ))
|
||||
|
||||
|
||||
async def main():
|
||||
|
||||
@@ -5,3 +5,4 @@ lxml>=5.1.0
|
||||
PyYAML>=6.0.1
|
||||
aiosqlite>=0.20.0
|
||||
click>=8.1.7
|
||||
browser_cookie3>=0.19.1
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
from click import Context
|
||||
|
||||
__all__ = ["help"]
|
||||
|
||||
|
||||
def help(ctx: Context, *args, **kwargs):
|
||||
ctx.exit()
|
||||
@@ -1,35 +1,51 @@
|
||||
from asyncio import run
|
||||
from contextlib import suppress
|
||||
from pathlib import Path as Root
|
||||
from textwrap import fill
|
||||
|
||||
from click import Context
|
||||
from click import (
|
||||
command,
|
||||
option,
|
||||
Path,
|
||||
Choice,
|
||||
pass_context,
|
||||
Context,
|
||||
echo,
|
||||
)
|
||||
from rich import print
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from source.application import XHS
|
||||
from source.expansion import BrowserCookie
|
||||
from source.module import (
|
||||
ROOT,
|
||||
PROJECT,
|
||||
)
|
||||
from source.module import Settings
|
||||
from .help import help
|
||||
from source.module import Translate
|
||||
|
||||
__all__ = ["cli"]
|
||||
|
||||
|
||||
def check_value(function):
|
||||
def inner(ctx: Context, param, value):
|
||||
if not value:
|
||||
return
|
||||
return function(ctx, param, value)
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
class CLI:
|
||||
def __init__(self, ctx: Context, **kwargs):
|
||||
# print(kwargs)
|
||||
self.ctx = ctx
|
||||
self.url = kwargs.pop("url")
|
||||
self.index = self.__format_index(kwargs.pop("index"))
|
||||
self.path = kwargs.pop("settings")
|
||||
self.update = kwargs.pop("update_settings")
|
||||
# print(kwargs)
|
||||
self.settings = Settings(self.__check_settings_path())
|
||||
self.parameter = self.settings.run() | self.__clean_params(kwargs)
|
||||
self.APP = XHS(**self.parameter)
|
||||
@@ -42,10 +58,8 @@ class CLI:
|
||||
await self.APP.__aexit__(exc_type, exc_value, traceback)
|
||||
|
||||
async def run(self):
|
||||
if not self.url:
|
||||
echo("No URL specified")
|
||||
self.ctx.exit()
|
||||
await self.APP.extract_cli(self.url, index=self.index)
|
||||
if self.url:
|
||||
await self.APP.extract_cli(self.url, index=self.index)
|
||||
self.__update_settings()
|
||||
|
||||
def __update_settings(self):
|
||||
@@ -58,11 +72,14 @@ class CLI:
|
||||
return s.parent if (s := Root(self.path)).is_file() else ROOT
|
||||
|
||||
@staticmethod
|
||||
def __clean_params(data: dict) -> dict:
|
||||
return {k: v for k, v in data.items() if v}
|
||||
def __merge_cookie(data: dict) -> None:
|
||||
if not data["cookie"] and (bc := data["browser_cookie"]):
|
||||
data["cookie"] = bc
|
||||
data.pop("browser_cookie")
|
||||
|
||||
def __check_params(self):
|
||||
pass
|
||||
def __clean_params(self, data: dict) -> dict:
|
||||
self.__merge_cookie(data)
|
||||
return {k: v for k, v in data.items() if v}
|
||||
|
||||
@staticmethod
|
||||
def __format_index(index: str) -> list:
|
||||
@@ -76,42 +93,101 @@ class CLI:
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def version(ctx: Context, *args, **kwargs):
|
||||
@check_value
|
||||
def version(ctx: Context, param, value) -> None:
|
||||
echo(PROJECT)
|
||||
ctx.exit()
|
||||
|
||||
@staticmethod
|
||||
@check_value
|
||||
def read_cookie(ctx: Context, param, value) -> str:
|
||||
if len(value) == 1:
|
||||
value = int(value)
|
||||
return BrowserCookie.get(value, domain="xiaohongshu.com")
|
||||
|
||||
@staticmethod
|
||||
@check_value
|
||||
def help_(ctx: Context, param, value) -> None:
|
||||
_ = Translate("").message()
|
||||
table = Table(highlight=True, box=None, show_header=True)
|
||||
|
||||
# 添加表格的列名
|
||||
table.add_column("parameter", no_wrap=True, style="bold")
|
||||
table.add_column("abbreviation", no_wrap=True, style="bold")
|
||||
table.add_column("type", no_wrap=True, style="bold")
|
||||
table.add_column("description", no_wrap=True, )
|
||||
|
||||
options = (
|
||||
("--url", "-u", "str", _("小红书作品链接")),
|
||||
("--index", "-i", "str", _("下载指定序号的图片文件,仅对图文作品生效;多个序号输入示例:\"1 3 5 7\"")),
|
||||
("--work_path", "-wp", "str", _("作品数据 / 文件保存根路径")),
|
||||
("--folder_name", "-fn", "str", _("作品文件储存文件夹名称")),
|
||||
("--user_agent", "-ua", "str", _("User-Agent")),
|
||||
("--cookie", "-ck", "str", _("小红书网页版 Cookie,无需登录")),
|
||||
("--proxy", "-p", "str", _("网络代理")),
|
||||
("--timeout", "-t", "int", _("请求数据超时限制,单位:秒")),
|
||||
("--chunk", "-c", "int", _("下载文件时,每次从服务器获取的数据块大小,单位:字节")),
|
||||
("--max_retry", "-mr", "int", _("请求数据失败时,重试的最大次数")),
|
||||
("--record_data", "-rd", "bool", _("是否记录作品数据至文件")),
|
||||
("--image_format", "-if", "choice", _("图文作品文件下载格式,支持:PNG、WEBP")),
|
||||
("--folder_mode", "-fm", "bool", _("是否将每个作品的文件储存至单独的文件夹")),
|
||||
("--language", "-l", "choice", _("设置程序语言,目前支持:zh_CN、en_GB")),
|
||||
("--settings", "-s", "str", _("读取指定配置文件")),
|
||||
("--browser_cookie", "-bc", "choice",
|
||||
fill(_("从指定的浏览器读取小红书网页版 Cookie,需要关闭对应的浏览器,支持:1 Chrome, 2 Chromium, 3 Opera, 4 Opera GX, "
|
||||
"5 Brave, 6 Edge, 7 Vivaldi, 8 Firefox, 9 LibreWolf, 10 Safari,输入浏览器类型或序号"), width=45)),
|
||||
("--update_settings", "-us", "flag", _("是否更新配置文件")),
|
||||
("--help", "-h", "flag", _("查看详细参数说明")),
|
||||
("--version", "-v", "flag", _("查看 XHS-Downloader 版本")),
|
||||
)
|
||||
|
||||
for option in options:
|
||||
table.add_row(*option)
|
||||
|
||||
print(
|
||||
Panel(
|
||||
table,
|
||||
border_style="bold",
|
||||
title="XHS-Downloader CLI Parameters",
|
||||
title_align="left"))
|
||||
ctx.exit()
|
||||
|
||||
|
||||
@command(name="XHS-Downloader", help=PROJECT)
|
||||
@option("--url", "-u", type=str, help="小红书作品链接", )
|
||||
@option("--index", "-i", type=str, help="下载指定序号的图片文件,仅对图文作品生效", )
|
||||
@option("--url", "-u", )
|
||||
@option("--index", "-i", )
|
||||
@option("--work_path",
|
||||
"-wp",
|
||||
type=Path(file_okay=False),
|
||||
help="作品数据 / 文件保存根路径",
|
||||
)
|
||||
@option("--folder_name", "-fn", type=str, help="作品文件储存文件夹名称", )
|
||||
@option("--user_agent", "-ua", type=str, help="请求头 User-Agent", )
|
||||
@option("--cookie", "-ck", type=str, help="小红书网页版 Cookie,无需登录", )
|
||||
@option("--proxy", "-p", type=str, help="设置程序代理", )
|
||||
@option("--timeout", "-t", type=int, help="请求数据超时限制,单位:秒", )
|
||||
@option("--chunk", "-c", type=int, help="下载文件时,每次从服务器获取的数据块大小,单位:字节", )
|
||||
@option("--max_retry", "-mr", type=int, help="请求数据失败时,重试的最大次数,单位:秒", )
|
||||
@option("--record_data", "-rd", type=bool, help="是否记录作品数据至 TXT 文件", )
|
||||
@option("--image_format", "-if", type=Choice(["png", "PNG", "webp", "WEBP"]),
|
||||
help="图文作品文件下载格式,支持:PNG、WEBP", )
|
||||
@option("--folder_mode", "-fm", type=bool, help="是否将每个作品的文件储存至单独的文件夹", )
|
||||
@option("--folder_name", "-fn", )
|
||||
@option("--user_agent", "-ua", )
|
||||
@option("--cookie", "-ck", )
|
||||
@option("--proxy", "-p", )
|
||||
@option("--timeout", "-t", type=int, )
|
||||
@option("--chunk", "-c", type=int, )
|
||||
@option("--max_retry", "-mr", type=int, )
|
||||
@option("--record_data", "-rd", type=bool, )
|
||||
@option("--image_format", "-if", type=Choice(["png", "PNG", "webp", "WEBP"]), )
|
||||
@option("--folder_mode", "-fm", type=bool, )
|
||||
@option("--language", "-l",
|
||||
type=Choice(["zh-CN", "en-GB"]), help="设置程序语言,目前支持:zh-CN、en-GB", )
|
||||
@option("--settings", "-s", type=Path(dir_okay=False), help="读取指定配置文件", )
|
||||
@option("--update_settings", "-us", type=bool, help="是否更新配置文件", )
|
||||
type=Choice(["zh_CN", "en_GB"]), )
|
||||
@option("--settings", "-s", type=Path(dir_okay=False), )
|
||||
@option("--browser_cookie", "-bc",
|
||||
type=Choice(list(BrowserCookie.SUPPORT_BROWSER.keys()) + [str(i) for i in range(1, 11)]),
|
||||
callback=CLI.read_cookie, )
|
||||
@option("--update_settings", "-us", type=bool,
|
||||
is_flag=True, )
|
||||
@option("-h",
|
||||
is_flag=True,
|
||||
is_eager=True,
|
||||
expose_value=False,
|
||||
help="查看详细参数说明",
|
||||
callback=help)
|
||||
@option("--version", "-v", is_flag=True, is_eager=True,
|
||||
expose_value=False, help="查看程序版本信息", callback=CLI.version)
|
||||
callback=CLI.help_, )
|
||||
@option("--version", "-v",
|
||||
is_flag=True,
|
||||
is_eager=True,
|
||||
expose_value=False,
|
||||
callback=CLI.version, )
|
||||
@pass_context
|
||||
def cli(ctx, **kwargs):
|
||||
async def main():
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from typing import Callable
|
||||
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.screen import Screen
|
||||
@@ -7,10 +10,9 @@ from textual.widgets import Label
|
||||
|
||||
from source.module import (
|
||||
PROJECT,
|
||||
)
|
||||
from source.translator import (
|
||||
Chinese,
|
||||
English,
|
||||
PROMPT,
|
||||
MASTER,
|
||||
INFO,
|
||||
)
|
||||
|
||||
__all__ = ["About"]
|
||||
@@ -32,13 +34,19 @@ class About(Screen):
|
||||
description="返回首页/Back"),
|
||||
]
|
||||
|
||||
def __init__(self, language: Chinese | English):
|
||||
def __init__(self, message: Callable[[str], str]):
|
||||
super().__init__()
|
||||
self.prompt = language
|
||||
self.message = message
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Label()
|
||||
yield Label(Text(self.message("如果 XHS-Downloader 对您有帮助,请考虑为它点个 Star,感谢您的支持!"), style=INFO),
|
||||
classes="prompt", )
|
||||
yield Label(Text(self.message("作者的其他开源项目"), style=PROMPT), classes="prompt", )
|
||||
yield Label(Text("TikTokDownloader (抖音 / TikTok)", style=MASTER), classes="prompt", )
|
||||
yield Label("https://github.com/JoeanAmier/TikTokDownloader")
|
||||
yield Label(Text("KS-Downloader (快手)", style=MASTER), classes="prompt", )
|
||||
yield Label("https://github.com/JoeanAmier/KS-Downloader")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Type
|
||||
from typing import Callable
|
||||
|
||||
from textual.app import App
|
||||
from textual.widgets import RichLog
|
||||
@@ -8,12 +8,8 @@ from source.module import (
|
||||
ROOT,
|
||||
)
|
||||
from source.module import Settings
|
||||
from source.translator import (
|
||||
LANGUAGE,
|
||||
Chinese,
|
||||
English,
|
||||
)
|
||||
# from .about import About
|
||||
from source.module import Translate
|
||||
from .about import About
|
||||
from .index import Index
|
||||
from .loading import Loading
|
||||
from .monitor import Monitor
|
||||
@@ -31,7 +27,7 @@ class XHSDownloader(App):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.parameter: dict
|
||||
self.prompt: Type[Chinese | English]
|
||||
self.message: Callable[[str], str]
|
||||
self.APP: XHS
|
||||
self.__initialization()
|
||||
|
||||
@@ -44,19 +40,19 @@ class XHSDownloader(App):
|
||||
|
||||
def __initialization(self) -> None:
|
||||
self.parameter = self.SETTINGS.run()
|
||||
self.prompt = LANGUAGE.get(self.parameter["language"], Chinese)
|
||||
self.APP = XHS(**self.parameter, language_object=self.prompt)
|
||||
self.message = Translate(self.parameter["language"]).message()
|
||||
self.APP = XHS(**self.parameter, transition=self.message)
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
self.install_screen(
|
||||
Setting(
|
||||
self.parameter,
|
||||
self.prompt),
|
||||
self.message),
|
||||
name="setting")
|
||||
self.install_screen(Index(self.APP, self.prompt), name="index")
|
||||
self.install_screen(Loading(self.prompt), name="loading")
|
||||
# self.install_screen(About(self.prompt), name="about")
|
||||
self.install_screen(Record(self.APP, self.prompt), name="record")
|
||||
self.install_screen(Index(self.APP, self.message), name="index")
|
||||
self.install_screen(Loading(self.message), name="loading")
|
||||
self.install_screen(About(self.message), name="about")
|
||||
self.install_screen(Record(self.APP, self.message), name="record")
|
||||
await self.push_screen("index")
|
||||
|
||||
async def action_settings(self):
|
||||
@@ -77,24 +73,24 @@ class XHSDownloader(App):
|
||||
|
||||
async def refresh_screen(self):
|
||||
self.pop_screen()
|
||||
await self.APP.recorder.database.close()
|
||||
await self.close_database()
|
||||
await self.APP.close()
|
||||
self.__initialization()
|
||||
await self.__aenter__()
|
||||
self.uninstall_screen("index")
|
||||
self.uninstall_screen("setting")
|
||||
self.uninstall_screen("loading")
|
||||
# self.uninstall_screen("about")
|
||||
self.uninstall_screen("about")
|
||||
self.uninstall_screen("record")
|
||||
self.install_screen(Index(self.APP, self.prompt), name="index")
|
||||
self.install_screen(Index(self.APP, self.message), name="index")
|
||||
self.install_screen(
|
||||
Setting(
|
||||
self.parameter,
|
||||
self.prompt),
|
||||
self.message),
|
||||
name="setting")
|
||||
self.install_screen(Loading(self.prompt), name="loading")
|
||||
# self.install_screen(About(self.prompt), name="about")
|
||||
self.install_screen(Record(self.APP, self.prompt), name="record")
|
||||
self.install_screen(Loading(self.message), name="loading")
|
||||
self.install_screen(About(self.message), name="about")
|
||||
self.install_screen(Record(self.APP, self.message), name="record")
|
||||
await self.push_screen("index")
|
||||
|
||||
def update_result(self, tip: str) -> None:
|
||||
@@ -103,11 +99,17 @@ class XHSDownloader(App):
|
||||
log.write(">" * 50)
|
||||
|
||||
async def action_check_update(self):
|
||||
await self.push_screen(Update(self.APP, self.prompt), callback=self.update_result)
|
||||
await self.push_screen(Update(self.APP, self.message), callback=self.update_result)
|
||||
|
||||
async def action_check_update_about(self):
|
||||
await self.push_screen("index")
|
||||
await self.action_check_update()
|
||||
|
||||
async def action_monitor(self):
|
||||
await self.push_screen(Monitor(self.APP, self.prompt))
|
||||
await self.push_screen(Monitor(self.APP, self.message))
|
||||
|
||||
async def close_database(self):
|
||||
await self.APP.id_recorder.cursor.close()
|
||||
await self.APP.id_recorder.database.close()
|
||||
await self.APP.data_recorder.cursor.close()
|
||||
await self.APP.data_recorder.database.close()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Callable
|
||||
|
||||
from pyperclip import paste
|
||||
from rich.text import Text
|
||||
from textual import on
|
||||
@@ -25,10 +27,6 @@ from source.module import (
|
||||
REPOSITORY,
|
||||
GENERAL,
|
||||
)
|
||||
from source.translator import (
|
||||
English,
|
||||
Chinese,
|
||||
)
|
||||
|
||||
__all__ = ["Index"]
|
||||
|
||||
@@ -40,13 +38,13 @@ class Index(Screen):
|
||||
Binding(key="s", action="settings", description="程序设置/Settings"),
|
||||
Binding(key="r", action="record", description="下载记录/Record"),
|
||||
Binding(key="m", action="monitor", description="开启监听/Monitor"),
|
||||
# Binding(key="a", action="about", description="关于项目/About"),
|
||||
Binding(key="a", action="about", description="关于项目/About"),
|
||||
]
|
||||
|
||||
def __init__(self, app: XHS, language: Chinese | English):
|
||||
def __init__(self, app: XHS, message: Callable[[str], str]):
|
||||
super().__init__()
|
||||
self.xhs = app
|
||||
self.prompt = language
|
||||
self.message = message
|
||||
self.url = None
|
||||
self.tip = None
|
||||
|
||||
@@ -55,24 +53,24 @@ class Index(Screen):
|
||||
yield ScrollableContainer(
|
||||
Label(
|
||||
Text(
|
||||
f"{self.prompt.open_source_protocol}{LICENCE}",
|
||||
f"{self.message("开源协议")}: {LICENCE}",
|
||||
style=MASTER)
|
||||
),
|
||||
Label(
|
||||
Text(
|
||||
f"{self.prompt.project_address}{REPOSITORY}",
|
||||
f"{self.message("项目地址")}{REPOSITORY}",
|
||||
style=MASTER)
|
||||
),
|
||||
Label(
|
||||
Text(
|
||||
self.prompt.input_box_title,
|
||||
self.message("请输入小红书图文/视频作品链接"),
|
||||
style=PROMPT), classes="prompt",
|
||||
),
|
||||
Input(placeholder=self.prompt.input_prompt),
|
||||
Input(placeholder=self.message("多个链接之间使用空格分隔")),
|
||||
HorizontalScroll(
|
||||
Button(self.prompt.download_button, id="deal"),
|
||||
Button(self.prompt.paste_button, id="paste"),
|
||||
Button(self.prompt.reset_button, id="reset"),
|
||||
Button(self.message("下载无水印作品文件"), id="deal"),
|
||||
Button(self.message("读取剪贴板"), id="paste"),
|
||||
Button(self.message("清空输入框"), id="reset"),
|
||||
),
|
||||
)
|
||||
yield RichLog(markup=True, wrap=True)
|
||||
@@ -82,14 +80,20 @@ class Index(Screen):
|
||||
self.title = PROJECT
|
||||
self.url = self.query_one(Input)
|
||||
self.tip = self.query_one(RichLog)
|
||||
self.tip.write(Text("\n".join(self.prompt.disclaimer), style=MASTER))
|
||||
self.tip.write(
|
||||
Text(
|
||||
self.message("免责声明\n") +
|
||||
f"\n{
|
||||
">" *
|
||||
50}",
|
||||
style=MASTER), scroll_end=False)
|
||||
|
||||
@on(Button.Pressed, "#deal")
|
||||
async def deal_button(self):
|
||||
if self.url.value:
|
||||
self.deal()
|
||||
else:
|
||||
self.tip.write(Text(self.prompt.invalid_link, style=WARNING))
|
||||
self.tip.write(Text(self.message("未输入任何小红书作品链接"), style=WARNING))
|
||||
self.tip.write(Text(">" * 50, style=GENERAL))
|
||||
|
||||
@on(Button.Pressed, "#reset")
|
||||
@@ -106,6 +110,6 @@ class Index(Screen):
|
||||
if any(await self.xhs.extract(self.url.value, True, log=self.tip)):
|
||||
self.url.value = ""
|
||||
else:
|
||||
self.tip.write(Text(self.prompt.download_failure, style=ERROR))
|
||||
self.tip.write(Text(self.message("下载小红书作品文件失败"), style=ERROR))
|
||||
self.tip.write(Text(">" * 50, style=GENERAL))
|
||||
self.app.pop_screen()
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
from typing import Callable
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Grid
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Label
|
||||
from textual.widgets import LoadingIndicator
|
||||
|
||||
from source.translator import (
|
||||
English,
|
||||
Chinese,
|
||||
)
|
||||
|
||||
__all__ = ["Loading"]
|
||||
|
||||
|
||||
class Loading(ModalScreen):
|
||||
def __init__(self, language: Chinese | English):
|
||||
def __init__(self, message: Callable[[str], str]):
|
||||
super().__init__()
|
||||
self.prompt = language
|
||||
self.message = message
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Grid(
|
||||
Label(self.prompt.processing),
|
||||
Label(self.message("程序处理中...")),
|
||||
LoadingIndicator(),
|
||||
classes="loading",
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Callable
|
||||
|
||||
from rich.text import Text
|
||||
from textual import on
|
||||
from textual import work
|
||||
@@ -16,10 +18,6 @@ from source.module import (
|
||||
MASTER,
|
||||
INFO,
|
||||
)
|
||||
from source.translator import (
|
||||
English,
|
||||
Chinese,
|
||||
)
|
||||
|
||||
__all__ = ["Monitor"]
|
||||
|
||||
@@ -30,16 +28,16 @@ class Monitor(Screen):
|
||||
Binding(key="c", action="close", description="关闭监听/Close"),
|
||||
]
|
||||
|
||||
def __init__(self, app: XHS, language: Chinese | English):
|
||||
def __init__(self, app: XHS, message: Callable[[str], str]):
|
||||
super().__init__()
|
||||
self.xhs = app
|
||||
self.prompt = language
|
||||
self.message = message
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Label(Text(self.prompt.monitor_mode, style=INFO), classes="prompt")
|
||||
yield Label(Text(self.message("已启动监听剪贴板模式"), style=INFO), classes="prompt")
|
||||
yield RichLog(markup=True, wrap=True)
|
||||
yield Button(self.prompt.close_monitor, id="close")
|
||||
yield Button(self.message("退出监听剪贴板模式"), id="close")
|
||||
yield Footer()
|
||||
|
||||
@on(Button.Pressed, "#close")
|
||||
@@ -54,7 +52,9 @@ class Monitor(Screen):
|
||||
def on_mount(self) -> None:
|
||||
self.title = PROJECT
|
||||
self.query_one(RichLog).write(
|
||||
Text(self.prompt.monitor_text, style=MASTER))
|
||||
Text(self.message(
|
||||
"程序会自动读取并提取剪贴板中的小红书作品链接,并自动下载链接对应的作品文件,如需关闭,请点击关闭按钮,或者向剪贴板写入 “close” 文本!"),
|
||||
style=MASTER))
|
||||
self.run_monitor()
|
||||
|
||||
def action_close(self):
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Callable
|
||||
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Grid
|
||||
@@ -8,32 +10,30 @@ from textual.widgets import Input
|
||||
from textual.widgets import Label
|
||||
|
||||
from source.application import XHS
|
||||
from source.translator import (
|
||||
Chinese,
|
||||
English,
|
||||
)
|
||||
|
||||
__all__ = ["Record"]
|
||||
|
||||
|
||||
class Record(ModalScreen):
|
||||
def __init__(self, app: XHS, language: Chinese | English):
|
||||
|
||||
def __init__(self, app: XHS, message: Callable[[str], str]):
|
||||
super().__init__()
|
||||
self.xhs = app
|
||||
self.prompt = language
|
||||
self.message = message
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Grid(
|
||||
Label(self.prompt.record_title, classes="prompt"),
|
||||
Input(placeholder=self.prompt.record_placeholder, id="id", ),
|
||||
Label(self.message("请输入待删除的小红书作品链接或作品 ID"), classes="prompt"),
|
||||
Input(placeholder=self.message("支持输入作品 ID 或包含作品 ID 的作品链接,多个链接或 ID 之间使用空格分隔"),
|
||||
id="id", ),
|
||||
HorizontalScroll(
|
||||
Button(self.prompt.record_enter_button, id="enter", ),
|
||||
Button(self.prompt.record_close_button, id="close"), ),
|
||||
Button(self.message("删除指定作品 ID"), id="enter", ),
|
||||
Button(self.message("返回首页"), id="close"), ),
|
||||
id="record",
|
||||
)
|
||||
|
||||
async def delete(self, text: str):
|
||||
await self.xhs.recorder.delete_many(text.split())
|
||||
await self.xhs.id_recorder.delete(text)
|
||||
|
||||
@on(Button.Pressed, "#enter")
|
||||
async def save_settings(self):
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Callable
|
||||
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
@@ -12,12 +14,6 @@ from textual.widgets import Input
|
||||
from textual.widgets import Label
|
||||
from textual.widgets import Select
|
||||
|
||||
from source.translator import (
|
||||
LANGUAGE,
|
||||
Chinese,
|
||||
English,
|
||||
)
|
||||
|
||||
__all__ = ["Setting"]
|
||||
|
||||
|
||||
@@ -27,66 +23,65 @@ class Setting(Screen):
|
||||
Binding(key="b", action="index", description="返回首页/Back"),
|
||||
]
|
||||
|
||||
def __init__(self, data: dict, language: Chinese | English):
|
||||
def __init__(self, data: dict, message: Callable[[str], str]):
|
||||
super().__init__()
|
||||
self.data = data
|
||||
self.prompt = language
|
||||
|
||||
self.message = message
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield ScrollableContainer(
|
||||
Label(self.prompt.work_path, classes="params", ),
|
||||
Input(self.data["work_path"], placeholder=self.prompt.work_path_placeholder, valid_empty=True,
|
||||
Label(self.message("作品数据 / 文件保存根路径"), classes="params", ),
|
||||
Input(self.data["work_path"], placeholder=self.message("程序根路径"), valid_empty=True,
|
||||
id="work_path", ),
|
||||
Label(self.prompt.folder_name, classes="params", ),
|
||||
Label(self.message("作品文件储存文件夹名称"), classes="params", ),
|
||||
Input(self.data["folder_name"], placeholder="Download", id="folder_name", ),
|
||||
Label(self.prompt.user_agent, classes="params", ),
|
||||
Input(self.data["user_agent"], placeholder=self.prompt.user_agent_placeholder, valid_empty=True,
|
||||
Label(self.message("User-Agent"), classes="params", ),
|
||||
Input(self.data["user_agent"], placeholder=self.message("默认 User-Agent"), valid_empty=True,
|
||||
id="user_agent", ),
|
||||
Label(self.prompt.cookie, classes="params", ),
|
||||
Label(self.message("小红书网页版 Cookie"), classes="params", ),
|
||||
Input(placeholder=self.__check_cookie(), valid_empty=True, id="cookie", ),
|
||||
Label(self.prompt.proxy, classes="params", ),
|
||||
Input(self.data["proxy"], placeholder=self.prompt.proxy_placeholder, valid_empty=True, id="proxy", ),
|
||||
Label(self.prompt.timeout, classes="params", ),
|
||||
Label(self.message("网络代理"), classes="params", ),
|
||||
Input(self.data["proxy"], placeholder=self.message("不使用代理"), valid_empty=True, id="proxy", ),
|
||||
Label(self.message("请求数据超时限制,单位:秒"), classes="params", ),
|
||||
Input(str(self.data["timeout"]), placeholder="10", type="integer", id="timeout", ),
|
||||
Label(self.prompt.chunk, classes="params", ),
|
||||
Label(self.message("下载文件时,每次从服务器获取的数据块大小,单位:字节"), classes="params", ),
|
||||
Input(str(self.data["chunk"]), placeholder="1048576", type="integer", id="chunk", ),
|
||||
Label(self.prompt.max_retry, classes="params", ),
|
||||
Label(self.message("请求数据失败时,重试的最大次数"), classes="params", ),
|
||||
Input(str(self.data["max_retry"]), placeholder="5", type="integer", id="max_retry", ),
|
||||
Container(
|
||||
Label("", classes="params", ),
|
||||
Label("", classes="params", ),
|
||||
Label(self.prompt.image_format, classes="params", ),
|
||||
Label(self.prompt.language, classes="params", ),
|
||||
Label(self.message("图片下载格式"), classes="params", ),
|
||||
Label(self.message("程序语言"), classes="params", ),
|
||||
classes="horizontal-layout",
|
||||
),
|
||||
Container(
|
||||
Checkbox(self.prompt.record_data, id="record_data", value=self.data["record_data"], ),
|
||||
Checkbox(self.prompt.folder_mode, id="folder_mode", value=self.data["folder_mode"], ),
|
||||
Checkbox(self.message("记录作品数据"), id="record_data", value=self.data["record_data"], ),
|
||||
Checkbox(self.message("作品文件夹归档模式"), id="folder_mode", value=self.data["folder_mode"], ),
|
||||
Select.from_values(
|
||||
("PNG", "WEBP"),
|
||||
value=self.data["image_format"],
|
||||
allow_blank=False,
|
||||
id="image_format"),
|
||||
Select.from_values(list(LANGUAGE.keys()),
|
||||
Select.from_values(["zh_CN", "en_GB"],
|
||||
value=self.data["language"],
|
||||
allow_blank=False,
|
||||
id="language", ),
|
||||
classes="horizontal-layout"),
|
||||
Container(
|
||||
Button(self.prompt.save_button, id="save", ),
|
||||
Button(self.prompt.abandon_button, id="abandon", ),
|
||||
Button(self.message("保存配置"), id="save", ),
|
||||
Button(self.message("放弃更改"), id="abandon", ),
|
||||
classes="settings_button", ),
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
def __check_cookie(self) -> str:
|
||||
if self.data["cookie"]:
|
||||
return self.prompt.cookie_placeholder_true
|
||||
return self.prompt.cookie_placeholder_false
|
||||
return self.message("小红书网页版 Cookie,无需登录,参数已设置")
|
||||
return self.message("小红书网页版 Cookie,无需登录,参数未设置")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.title = self.prompt.settings_title
|
||||
self.title = self.message("程序设置")
|
||||
|
||||
@on(Button.Pressed, "#save")
|
||||
def save_settings(self):
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Callable
|
||||
|
||||
from aiohttp import ClientTimeout
|
||||
from rich.text import Text
|
||||
from textual import work
|
||||
@@ -17,23 +19,19 @@ from source.module import (
|
||||
INFO,
|
||||
RELEASES,
|
||||
)
|
||||
from source.translator import (
|
||||
English,
|
||||
Chinese,
|
||||
)
|
||||
|
||||
__all__ = ["Update"]
|
||||
|
||||
|
||||
class Update(ModalScreen):
|
||||
def __init__(self, app: XHS, language: Chinese | English):
|
||||
def __init__(self, app: XHS, message: Callable[[str], str]):
|
||||
super().__init__()
|
||||
self.xhs = app
|
||||
self.prompt = language
|
||||
self.message = message
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Grid(
|
||||
Label(self.prompt.check_update_notification),
|
||||
Label(self.message("正在检查新版本,请稍等...")),
|
||||
LoadingIndicator(),
|
||||
classes="loading",
|
||||
)
|
||||
@@ -45,25 +43,22 @@ class Update(ModalScreen):
|
||||
latest_major, latest_minor = map(
|
||||
int, url.split("/")[-1].split(".", 1))
|
||||
if latest_major > VERSION_MAJOR or latest_minor > VERSION_MINOR:
|
||||
tip = Text(
|
||||
f"{self.prompt.official_version_update(
|
||||
latest_major,
|
||||
latest_minor)}\n{RELEASES}",
|
||||
style=WARNING)
|
||||
tip = Text(f"{self.message("检测到新版本:{0}.{1}").format(
|
||||
VERSION_MAJOR, VERSION_MINOR)}\n{RELEASES}", style=WARNING)
|
||||
elif latest_minor == VERSION_MINOR and VERSION_BETA:
|
||||
tip = Text(
|
||||
f"{self.prompt.development_version_update}\n{RELEASES}",
|
||||
f"{self.message("当前版本为开发版, 可更新至正式版")}\n{RELEASES}",
|
||||
style=WARNING)
|
||||
elif VERSION_BETA:
|
||||
tip = Text(
|
||||
self.prompt.latest_development_version,
|
||||
self.message("当前已是最新开发版"),
|
||||
style=WARNING)
|
||||
else:
|
||||
tip = Text(
|
||||
self.prompt.latest_official_version,
|
||||
self.message("当前已是最新正式版"),
|
||||
style=INFO)
|
||||
except ValueError:
|
||||
tip = Text(self.prompt.check_update_failure, style=ERROR)
|
||||
tip = Text(self.message("检测新版本失败"), style=ERROR)
|
||||
self.dismiss(tip)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
|
||||
@@ -4,12 +4,15 @@ from asyncio import QueueEmpty
|
||||
from asyncio import gather
|
||||
from asyncio import sleep
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from re import compile
|
||||
from typing import Callable
|
||||
|
||||
from pyperclip import paste
|
||||
|
||||
from source.expansion import Converter
|
||||
from source.expansion import Namespace
|
||||
from source.module import DataRecorder
|
||||
from source.module import IDRecorder
|
||||
from source.module import Manager
|
||||
from source.module import (
|
||||
@@ -17,13 +20,8 @@ from source.module import (
|
||||
ERROR,
|
||||
WARNING,
|
||||
)
|
||||
from source.module import Translate
|
||||
from source.module import logging
|
||||
from source.module import wait
|
||||
from source.translator import (
|
||||
LANGUAGE,
|
||||
Chinese,
|
||||
English,
|
||||
)
|
||||
from .download import Download
|
||||
from .explore import Explore
|
||||
from .image import Image
|
||||
@@ -56,11 +54,16 @@ class XHS:
|
||||
max_retry=5,
|
||||
record_data=False,
|
||||
image_format="PNG",
|
||||
image_download=True,
|
||||
video_download=True,
|
||||
folder_mode=False,
|
||||
language="zh-CN",
|
||||
language_object: Chinese | English = None,
|
||||
language="zh_CN",
|
||||
server=False,
|
||||
transition: Callable[[str], str] = None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
self.prompt = language_object or LANGUAGE.get(language, Chinese)
|
||||
self.message = transition or Translate(language).message()
|
||||
self.manager = Manager(
|
||||
ROOT,
|
||||
work_path,
|
||||
@@ -74,7 +77,7 @@ class XHS:
|
||||
record_data,
|
||||
image_format,
|
||||
folder_mode,
|
||||
self.prompt,
|
||||
self.message,
|
||||
)
|
||||
self.html = Html(self.manager)
|
||||
self.image = Image()
|
||||
@@ -82,7 +85,8 @@ class XHS:
|
||||
self.explore = Explore()
|
||||
self.convert = Converter()
|
||||
self.download = Download(self.manager)
|
||||
self.recorder = IDRecorder(self.manager)
|
||||
self.id_recorder = IDRecorder(self.manager)
|
||||
self.data_recorder = DataRecorder(self.manager)
|
||||
self.clipboard_cache: str = ""
|
||||
self.queue = Queue()
|
||||
self.event = Event()
|
||||
@@ -96,49 +100,53 @@ class XHS:
|
||||
|
||||
async def __download_files(self, container: dict, download: bool, index, log, bar):
|
||||
name = self.__naming_rules(container)
|
||||
path = self.manager.folder
|
||||
if (u := container["下载地址"]) and download:
|
||||
if await self.skip_download(i := container["作品ID"]):
|
||||
logging(log, self.prompt.exist_record(i))
|
||||
logging(
|
||||
log, self.message("作品 {0} 存在下载记录,跳过下载").format(i))
|
||||
else:
|
||||
path, result = await self.download.run(u, index, name, container["作品类型"], log, bar)
|
||||
await self.__add_record(i, result)
|
||||
elif not u:
|
||||
logging(log, self.prompt.download_link_error, ERROR)
|
||||
self.manager.save_data(path, name, container)
|
||||
logging(log, self.message("提取作品文件下载地址失败"), ERROR)
|
||||
await self.save_data(container)
|
||||
|
||||
async def save_data(self, data: dict, ):
|
||||
data["采集时间"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
data["下载地址"] = " ".join(data["下载地址"])
|
||||
await self.data_recorder.add(**data)
|
||||
|
||||
async def __add_record(self, id_: str, result: tuple) -> None:
|
||||
if all(result):
|
||||
await self.recorder.add(id_)
|
||||
await self.id_recorder.add(id_)
|
||||
|
||||
async def extract(self,
|
||||
url: str,
|
||||
download=False,
|
||||
index: list | tuple = None,
|
||||
efficient=False,
|
||||
log=None,
|
||||
bar=None) -> list[dict]:
|
||||
# return # 调试代码
|
||||
urls = await self.__extract_links(url, log)
|
||||
if not urls:
|
||||
logging(log, self.prompt.extract_link_failure, WARNING)
|
||||
logging(log, self.message("提取小红书作品链接失败"), WARNING)
|
||||
else:
|
||||
logging(log, self.prompt.pending_processing(len(urls)))
|
||||
logging(
|
||||
log, self.message("共 {0} 个小红书作品待处理...").format(len(urls)))
|
||||
# return urls # 调试代码
|
||||
return [await self.__deal_extract(i, download, index, efficient, log, bar) for i in urls]
|
||||
return [await self.__deal_extract(i, download, index, log, bar, ) for i in urls]
|
||||
|
||||
async def extract_cli(self,
|
||||
url: str,
|
||||
download=True,
|
||||
index: list | tuple = None,
|
||||
efficient=True,
|
||||
log=None,
|
||||
bar=None) -> None:
|
||||
url = await self.__extract_links(url, log)
|
||||
if not url:
|
||||
logging(log, self.prompt.extract_link_failure, WARNING)
|
||||
logging(log, self.message("提取小红书作品链接失败"), WARNING)
|
||||
else:
|
||||
await self.__deal_extract(url[0], download, index, efficient, log, bar)
|
||||
await self.__deal_extract(url[0], download, index, log, bar)
|
||||
|
||||
async def __extract_links(self, url: str, log) -> list:
|
||||
urls = []
|
||||
@@ -152,18 +160,17 @@ class XHS:
|
||||
urls.append(u.group())
|
||||
return urls
|
||||
|
||||
async def __deal_extract(self, url: str, download: bool, index: list | tuple | None, efficient: bool, log, bar):
|
||||
logging(log, self.prompt.start_processing(url))
|
||||
async def __deal_extract(self, url: str, download: bool, index: list | tuple | None, log, bar):
|
||||
logging(log, self.message("开始处理作品:{0}").format(url))
|
||||
html = await self.html.request_url(url, log=log)
|
||||
namespace = self.__generate_data_object(html)
|
||||
if not namespace:
|
||||
logging(log, self.prompt.get_data_failure(url), ERROR)
|
||||
logging(log, self.message("{0} 获取数据失败").format(url), ERROR)
|
||||
return {}
|
||||
await self.__suspend(efficient)
|
||||
data = self.explore.run(namespace)
|
||||
# logging(log, data) # 调试代码
|
||||
if not data:
|
||||
logging(log, self.prompt.extract_data_failure(url), ERROR)
|
||||
logging(log, self.message("{0} 提取数据失败").format(url), ERROR)
|
||||
return {}
|
||||
match data["作品类型"]:
|
||||
case "视频":
|
||||
@@ -173,7 +180,7 @@ class XHS:
|
||||
case _:
|
||||
data["下载地址"] = []
|
||||
await self.__download_files(data, download, index, log, bar)
|
||||
logging(log, self.prompt.processing_completed(url))
|
||||
logging(log, self.message("作品处理完成:{0}").format(url))
|
||||
return data
|
||||
|
||||
def __generate_data_object(self, html: str) -> Namespace:
|
||||
@@ -188,7 +195,7 @@ class XHS:
|
||||
|
||||
async def monitor(self, delay=1, download=False, efficient=False, log=None, bar=None) -> None:
|
||||
self.event.clear()
|
||||
await gather(self.__push_link(delay), self.__receive_link(delay, download, efficient, log, bar))
|
||||
await gather(self.__push_link(delay), self.__receive_link(delay, download, None, efficient, log, bar))
|
||||
|
||||
async def __push_link(self, delay: int):
|
||||
while not self.event.is_set():
|
||||
@@ -209,20 +216,16 @@ class XHS:
|
||||
self.event.set()
|
||||
|
||||
async def skip_download(self, id_: str) -> bool:
|
||||
return bool(await self.recorder.select(id_))
|
||||
|
||||
@staticmethod
|
||||
async def __suspend(efficient: bool) -> None:
|
||||
if efficient:
|
||||
return
|
||||
await wait()
|
||||
return bool(await self.id_recorder.select(id_))
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.recorder.__aenter__()
|
||||
await self.id_recorder.__aenter__()
|
||||
await self.data_recorder.__aenter__()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_value, traceback):
|
||||
await self.recorder.__aexit__(exc_type, exc_value, traceback)
|
||||
await self.id_recorder.__aexit__(exc_type, exc_value, traceback)
|
||||
await self.data_recorder.__aexit__(exc_type, exc_value, traceback)
|
||||
await self.close()
|
||||
|
||||
async def close(self):
|
||||
|
||||
@@ -28,7 +28,7 @@ class Download:
|
||||
self.chunk = manager.chunk
|
||||
self.session = manager.download_session
|
||||
self.retry = manager.retry
|
||||
self.prompt = manager.prompt
|
||||
self.message = manager.message
|
||||
self.folder_mode = manager.folder_mode
|
||||
self.video_format = "mp4"
|
||||
self.image_format = manager.image_format
|
||||
@@ -68,7 +68,7 @@ class Download:
|
||||
name: str,
|
||||
log) -> list:
|
||||
if any(path.glob(f"{name}.*")):
|
||||
logging(log, self.prompt.skip_download(name))
|
||||
logging(log, self.message("{0} 文件已存在,跳过下载").format(name))
|
||||
return []
|
||||
return [(urls[0], name, self.video_format)]
|
||||
|
||||
@@ -85,7 +85,9 @@ class Download:
|
||||
continue
|
||||
file = f"{name}_{i}"
|
||||
if any(path.glob(f"{file}.*")):
|
||||
logging(log, self.prompt.skip_download(file))
|
||||
logging(
|
||||
log, self.message(
|
||||
"{0} 文件已存在,跳过下载").format(name))
|
||||
continue
|
||||
tasks.append([j, file, self.image_format])
|
||||
return tasks
|
||||
@@ -95,6 +97,9 @@ class Download:
|
||||
try:
|
||||
async with self.session.get(url, proxy=self.proxy) as response:
|
||||
if response.status != 200:
|
||||
logging(
|
||||
log, self.message("链接 {0} 请求失败,响应码 {1}").format(
|
||||
url, response.status), style=ERROR)
|
||||
return False
|
||||
suffix = self.__extract_type(
|
||||
response.headers.get("Content-Type")) or format_
|
||||
@@ -110,13 +115,15 @@ class Download:
|
||||
# self.__update_progress(bar, len(chunk))
|
||||
self.manager.move(temp, real)
|
||||
# self.__create_progress(bar, None)
|
||||
logging(log, self.prompt.download_success(name))
|
||||
logging(log, self.message("文件 {0} 下载成功").format(name))
|
||||
return True
|
||||
except ClientError as error:
|
||||
self.manager.delete(temp)
|
||||
# self.__create_progress(bar, None)
|
||||
logging(log, str(error), ERROR)
|
||||
logging(log, self.prompt.download_error(name), ERROR)
|
||||
logging(
|
||||
log, self.message(
|
||||
"网络异常,{0} 下载失败").format(name), ERROR)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -25,18 +25,23 @@ class Explore:
|
||||
@staticmethod
|
||||
def __extract_interact_info(container: dict, data: Namespace) -> None:
|
||||
container["收藏数量"] = data.safe_extract(
|
||||
"interactInfo.collectedCount", -1)
|
||||
container["评论数量"] = data.safe_extract("interactInfo.commentCount", -1)
|
||||
container["分享数量"] = data.safe_extract("interactInfo.shareCount", -1)
|
||||
container["点赞数量"] = data.safe_extract("interactInfo.likedCount", -1)
|
||||
"interactInfo.collectedCount", "-1")
|
||||
container["评论数量"] = data.safe_extract(
|
||||
"interactInfo.commentCount", "-1")
|
||||
container["分享数量"] = data.safe_extract("interactInfo.shareCount", "-1")
|
||||
container["点赞数量"] = data.safe_extract("interactInfo.likedCount", "-1")
|
||||
|
||||
@staticmethod
|
||||
def __extract_tags(container: dict, data: Namespace):
|
||||
tags = data.safe_extract("tagList", [])
|
||||
container["作品标签"] = [Namespace.object_extract(i, "name") for i in tags]
|
||||
container["作品标签"] = " ".join(
|
||||
Namespace.object_extract(
|
||||
i, "name") for i in tags)
|
||||
|
||||
def __extract_info(self, container: dict, data: Namespace):
|
||||
container["作品ID"] = data.safe_extract("noteId")
|
||||
container["作品链接"] = f"https://www.xiaohongshu.com/explore/{
|
||||
container["作品ID"]}"
|
||||
container["作品标题"] = data.safe_extract("title")
|
||||
container["作品描述"] = data.safe_extract("desc")
|
||||
container["作品类型"] = self.explore_type.get(
|
||||
@@ -59,3 +64,5 @@ class Explore:
|
||||
def __extract_user(container: dict, data: Namespace):
|
||||
container["作者昵称"] = data.safe_extract("user.nickname")
|
||||
container["作者ID"] = data.safe_extract("user.userId")
|
||||
container["作者链接"] = f"https://www.xiaohongshu.com/user/profile/{
|
||||
container["作者ID"]}"
|
||||
|
||||
@@ -8,22 +8,19 @@ class Image:
|
||||
@classmethod
|
||||
def get_image_link(cls, data: Namespace, format_: str) -> list:
|
||||
images = data.safe_extract("imageList", [])
|
||||
token_list = [
|
||||
cls.__extract_image_token(
|
||||
Namespace.object_extract(
|
||||
i, "urlDefault")) for i in images]
|
||||
match format_:
|
||||
case "png":
|
||||
return [
|
||||
Html.format_url(
|
||||
cls.__generate_png_link(
|
||||
cls.__extract_png_token(Namespace.object_extract(
|
||||
i,
|
||||
"urlDefault")))) for i in images]
|
||||
return [Html.format_url(cls.__generate_png_link(i))
|
||||
for i in token_list]
|
||||
case "webp":
|
||||
return [
|
||||
Html.format_url(
|
||||
cls.__generate_webp_link(
|
||||
cls.__extract_webp_token(Namespace.object_extract(
|
||||
i,
|
||||
"urlDefault")))) for i in images]
|
||||
raise ValueError
|
||||
return [Html.format_url(cls.__generate_webp_link(i))
|
||||
for i in token_list]
|
||||
case _:
|
||||
raise ValueError
|
||||
|
||||
@staticmethod
|
||||
def __generate_webp_link(token: str) -> str:
|
||||
@@ -34,9 +31,5 @@ class Image:
|
||||
return f"https://ci.xiaohongshu.com/{token}?imageView2/2/w/format/png"
|
||||
|
||||
@staticmethod
|
||||
def __extract_webp_token(url: str) -> str:
|
||||
def __extract_image_token(url: str) -> str:
|
||||
return "/".join(url.split("/")[5:]).split("!")[0]
|
||||
|
||||
@staticmethod
|
||||
def __extract_png_token(url: str) -> str:
|
||||
return url.split("/")[-1].split("!")[0]
|
||||
|
||||
@@ -12,7 +12,7 @@ class Html:
|
||||
def __init__(self, manager: Manager, ):
|
||||
self.proxy = manager.proxy
|
||||
self.retry = manager.retry
|
||||
self.prompt = manager.prompt
|
||||
self.message = manager.message
|
||||
self.session = manager.request_session
|
||||
|
||||
@retry
|
||||
@@ -34,7 +34,8 @@ class Html:
|
||||
return await response.text() if content else str(response.url)
|
||||
except ClientError as error:
|
||||
logging(log, str(error), ERROR)
|
||||
logging(log, self.prompt.request_error(url), ERROR)
|
||||
logging(
|
||||
log, self.message("网络异常,请求 {0} 失败").format(url), ERROR)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from .browser import BrowserCookie
|
||||
from .converter import Converter
|
||||
from .namespace import Namespace
|
||||
|
||||
__all__ = ["Converter", "Namespace", ]
|
||||
__all__ = ["Converter", "Namespace", "BrowserCookie", ]
|
||||
|
||||
62
source/expansion/browser.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from browser_cookie3 import (
|
||||
chrome,
|
||||
chromium,
|
||||
opera,
|
||||
opera_gx,
|
||||
brave,
|
||||
edge,
|
||||
vivaldi,
|
||||
firefox,
|
||||
librewolf,
|
||||
safari,
|
||||
BrowserCookieError,
|
||||
)
|
||||
|
||||
__all__ = ["BrowserCookie"]
|
||||
|
||||
|
||||
class BrowserCookie:
|
||||
SUPPORT_BROWSER = {
|
||||
"chrome": chrome,
|
||||
"chromium": chromium,
|
||||
"opera": opera,
|
||||
"opera_gx": opera_gx,
|
||||
"brave": brave,
|
||||
"edge": edge,
|
||||
"vivaldi": vivaldi,
|
||||
"firefox": firefox,
|
||||
"librewolf": librewolf,
|
||||
"safari": safari,
|
||||
|
||||
"Chrome": chrome,
|
||||
"Chromium": chromium,
|
||||
"Opera": opera,
|
||||
"Opera_gx": opera_gx,
|
||||
"Brave": brave,
|
||||
"Edge": edge,
|
||||
"Vivaldi": vivaldi,
|
||||
"Firefox": firefox,
|
||||
"Librewolf": librewolf,
|
||||
"Safari": safari,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get(cls, browser: str | int, domain: str) -> str:
|
||||
browser = cls.__browser_object(browser)
|
||||
try:
|
||||
cookie = browser(domain_name=domain)
|
||||
cookie = [f"{i.name}={i.value}" for i in cookie]
|
||||
return "; ".join(cookie)
|
||||
except PermissionError:
|
||||
print("读取 Cookie 失败,浏览器未关闭!")
|
||||
except BrowserCookieError:
|
||||
print("获取 Cookie 失败,未找到 Cookie 数据!")
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def __browser_object(cls, browser: str | int):
|
||||
if isinstance(browser, str):
|
||||
return cls.SUPPORT_BROWSER[browser]
|
||||
elif isinstance(browser, int):
|
||||
return list(cls.SUPPORT_BROWSER.values())[browser - 1]
|
||||
raise TypeError
|
||||
@@ -1,5 +1,6 @@
|
||||
from .extend import Account
|
||||
from .manager import Manager
|
||||
from .recorder import DataRecorder
|
||||
from .recorder import IDRecorder
|
||||
from .settings import Settings
|
||||
from .static import (
|
||||
@@ -25,8 +26,8 @@ from .static import (
|
||||
from .tools import (
|
||||
retry,
|
||||
logging,
|
||||
wait,
|
||||
)
|
||||
from .translator import Translate
|
||||
|
||||
__all__ = [
|
||||
"Account",
|
||||
@@ -52,6 +53,7 @@ __all__ = [
|
||||
"HEADERS",
|
||||
"retry",
|
||||
"logging",
|
||||
"wait",
|
||||
"PROJECT",
|
||||
"Translate",
|
||||
"DataRecorder",
|
||||
]
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
from datetime import datetime
|
||||
from json import dumps
|
||||
from pathlib import Path
|
||||
from re import compile
|
||||
from re import sub
|
||||
from shutil import move
|
||||
from shutil import rmtree
|
||||
from typing import Callable
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from aiohttp import ClientTimeout
|
||||
|
||||
from source.translator import Chinese
|
||||
from source.translator import English
|
||||
from .static import HEADERS
|
||||
from .static import USERAGENT
|
||||
|
||||
@@ -34,7 +31,7 @@ class Manager:
|
||||
record_data: bool,
|
||||
image_format: str,
|
||||
folder_mode: bool,
|
||||
language: Chinese | English,
|
||||
transition: Callable[[str], str],
|
||||
):
|
||||
self.root = root
|
||||
self.temp = root.joinpath("./temp")
|
||||
@@ -57,7 +54,7 @@ class Manager:
|
||||
self.download_session = ClientSession(
|
||||
headers=self.blank_headers,
|
||||
timeout=ClientTimeout(connect=timeout))
|
||||
self.prompt = language
|
||||
self.message = transition
|
||||
|
||||
def __check_path(self, path: str) -> Path:
|
||||
if not path:
|
||||
@@ -108,21 +105,6 @@ class Manager:
|
||||
name = self.NAME.sub("_", name)
|
||||
return sub(r"_+", "_", name).strip("_")
|
||||
|
||||
def save_data(self, path: Path, name: str, data: dict):
|
||||
if not self.record_data:
|
||||
return
|
||||
with path.joinpath(f"{name}.txt").open("a", encoding="utf-8") as f:
|
||||
time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
content = f"{
|
||||
time.center(
|
||||
50,
|
||||
"=")}\n{
|
||||
dumps(
|
||||
data,
|
||||
indent=4,
|
||||
ensure_ascii=False)}\n"
|
||||
f.write(content)
|
||||
|
||||
async def close(self):
|
||||
await self.request_session.close()
|
||||
await self.download_session.close()
|
||||
|
||||
@@ -1,46 +1,109 @@
|
||||
from re import compile
|
||||
|
||||
from aiosqlite import connect
|
||||
|
||||
from source.module import Manager
|
||||
|
||||
__all__ = ["IDRecorder"]
|
||||
__all__ = ["IDRecorder", "DataRecorder", ]
|
||||
|
||||
|
||||
class IDRecorder:
|
||||
URL = compile(r"\S*?https://www\.xiaohongshu\.com/explore/(\S+)")
|
||||
|
||||
def __init__(self, manager: Manager):
|
||||
self.file = manager.root.joinpath("XHS-Downloader.db")
|
||||
self.file = manager.root.joinpath("ExploreID.db")
|
||||
self.database = None
|
||||
self.cursor = None
|
||||
|
||||
async def __connect_database(self):
|
||||
async def _connect_database(self):
|
||||
self.database = await connect(self.file)
|
||||
self.cursor = await self.database.cursor()
|
||||
await self.database.execute("CREATE TABLE IF NOT EXISTS explore_ids (ID TEXT PRIMARY KEY);")
|
||||
await self.database.execute("CREATE TABLE IF NOT EXISTS explore_id (ID TEXT PRIMARY KEY);")
|
||||
await self.database.commit()
|
||||
|
||||
async def select(self, id_: str):
|
||||
await self.cursor.execute("SELECT ID FROM explore_ids WHERE ID=?", (id_,))
|
||||
await self.cursor.execute("SELECT ID FROM explore_id WHERE ID=?", (id_,))
|
||||
return await self.cursor.fetchone()
|
||||
|
||||
async def add(self, id_: str) -> None:
|
||||
await self.database.execute("REPLACE INTO explore_ids VALUES (?);", (id_,))
|
||||
await self.database.execute("REPLACE INTO explore_id VALUES (?);", (id_,))
|
||||
await self.database.commit()
|
||||
|
||||
async def delete(self, id_: str) -> None:
|
||||
async def __delete(self, id_: str) -> None:
|
||||
if id_:
|
||||
await self.database.execute("DELETE FROM explore_ids WHERE ID=?", (id_,))
|
||||
await self.database.execute("DELETE FROM explore_id WHERE ID=?", (id_,))
|
||||
await self.database.commit()
|
||||
|
||||
async def delete_many(self, ids: list | tuple):
|
||||
[await self.delete(i) for i in ids]
|
||||
async def delete(self, ids: str):
|
||||
ids = [i.group(1) for i in self.URL.finditer(ids)]
|
||||
[await self.__delete(i) for i in ids]
|
||||
|
||||
async def all(self):
|
||||
await self.cursor.execute("SELECT ID FROM explore_ids")
|
||||
await self.cursor.execute("SELECT ID FROM explore_id")
|
||||
return [i[0] for i in await self.cursor.fetchmany()]
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.__connect_database()
|
||||
await self._connect_database()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_value, traceback):
|
||||
await self.cursor.close()
|
||||
await self.database.close()
|
||||
|
||||
|
||||
class DataRecorder(IDRecorder):
|
||||
DATA_TABLE = (
|
||||
("采集时间", "TEXT"),
|
||||
("作品ID", "TEXT PRIMARY KEY"),
|
||||
("作品类型", "TEXT"),
|
||||
("作品标题", "TEXT"),
|
||||
("作品描述", "TEXT"),
|
||||
("作品标签", "TEXT"),
|
||||
("发布时间", "TEXT"),
|
||||
("最后更新时间", "TEXT"),
|
||||
("收藏数量", "TEXT"),
|
||||
("评论数量", "TEXT"),
|
||||
("分享数量", "TEXT"),
|
||||
("点赞数量", "TEXT"),
|
||||
("作者昵称", "TEXT"),
|
||||
("作者ID", "TEXT"),
|
||||
("IP归属地", "TEXT"),
|
||||
("作者链接", "TEXT"),
|
||||
("作品链接", "TEXT"),
|
||||
("下载地址", "TEXT"),
|
||||
)
|
||||
|
||||
def __init__(self, manager: Manager):
|
||||
super().__init__(manager)
|
||||
self.file = manager.folder.joinpath("ExploreData.db")
|
||||
|
||||
async def _connect_database(self):
|
||||
self.database = await connect(self.file)
|
||||
self.cursor = await self.database.cursor()
|
||||
await self.database.execute(f"""CREATE TABLE IF NOT EXISTS explore_data (
|
||||
{",".join(" ".join(i) for i in self.DATA_TABLE)}
|
||||
);""")
|
||||
await self.database.commit()
|
||||
|
||||
async def select(self, id_: str):
|
||||
pass
|
||||
|
||||
async def add(self, **kwargs) -> None:
|
||||
await self.database.execute(f"""REPLACE INTO explore_data (
|
||||
{", ".join(i[0] for i in self.DATA_TABLE)}
|
||||
) VALUES (
|
||||
{", ".join("?" for _ in kwargs)}
|
||||
);""", self.__generate_values(kwargs))
|
||||
await self.database.commit()
|
||||
|
||||
async def __delete(self, id_: str) -> None:
|
||||
pass
|
||||
|
||||
async def delete(self, ids: list | tuple):
|
||||
pass
|
||||
|
||||
async def all(self):
|
||||
pass
|
||||
|
||||
def __generate_values(self, data: dict) -> tuple:
|
||||
return tuple(data[i] for i, _ in self.DATA_TABLE)
|
||||
|
||||
@@ -18,9 +18,11 @@ class Settings:
|
||||
"max_retry": 5,
|
||||
"record_data": False,
|
||||
"image_format": "PNG",
|
||||
"image_download": True,
|
||||
"video_download": True,
|
||||
"folder_mode": False,
|
||||
"language": "zh-CN",
|
||||
# "server": False,
|
||||
"language": "zh_CN",
|
||||
"server": False,
|
||||
}
|
||||
encode = "UTF-8-SIG" if system() == "Windows" else "UTF-8"
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ __all__ = [
|
||||
|
||||
VERSION_MAJOR = 1
|
||||
VERSION_MINOR = 9
|
||||
VERSION_BETA = True
|
||||
VERSION_BETA = False
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
PROJECT = f"XHS-Downloader V{VERSION_MAJOR}.{
|
||||
VERSION_MINOR}{" Beta" if VERSION_BETA else ""}"
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
from asyncio import sleep
|
||||
from random import randint
|
||||
|
||||
from rich import print
|
||||
from rich.text import Text
|
||||
|
||||
from .static import INFO
|
||||
|
||||
__all__ = ["retry", "logging", "wait"]
|
||||
__all__ = ["retry", "logging", ]
|
||||
|
||||
|
||||
def retry(function):
|
||||
@@ -26,7 +24,3 @@ def logging(log, text, style=INFO):
|
||||
log.write(string)
|
||||
else:
|
||||
print(string)
|
||||
|
||||
|
||||
async def wait():
|
||||
await sleep(randint(15, 45) * 0.1)
|
||||
|
||||
27
source/module/translator.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from gettext import translation
|
||||
|
||||
from source.module import ROOT
|
||||
|
||||
__all__ = ["Translate"]
|
||||
|
||||
|
||||
class Translate:
|
||||
SUPPORT = {
|
||||
"zh_CN",
|
||||
"en_GB",
|
||||
}
|
||||
|
||||
def __init__(self, language: str):
|
||||
self.language = self.__check_language(language)
|
||||
self.translate = translation(
|
||||
"xhs",
|
||||
localedir=ROOT.joinpath("locale"),
|
||||
languages=[self.language],
|
||||
fallback=True,
|
||||
)
|
||||
|
||||
def __check_language(self, language: str) -> str:
|
||||
return language if language in self.SUPPORT else "zh_CN"
|
||||
|
||||
def message(self):
|
||||
return self.translate.gettext
|
||||
@@ -1,13 +0,0 @@
|
||||
from .chinese import Chinese
|
||||
from .english import English
|
||||
|
||||
__all__ = [
|
||||
"LANGUAGE",
|
||||
"Chinese",
|
||||
"English",
|
||||
]
|
||||
|
||||
LANGUAGE = {
|
||||
Chinese.code: Chinese,
|
||||
English.code: English,
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
__all__ = ["Chinese"]
|
||||
|
||||
|
||||
class Chinese:
|
||||
code: str = "zh-CN"
|
||||
disclaimer: tuple[str] = (
|
||||
"关于 XHS-Downloader 的 免责声明:",
|
||||
"",
|
||||
"1.使用者对本项目的使用由使用者自行决定,并自行承担风险。作者对使用者使用本项目所产生的任何损失、责任、或风险概不负责。",
|
||||
"2.本项目的作者提供的代码和功能是基于现有知识和技术的开发成果。作者尽力确保代码的正确性和安全性,但不保证代码完全没有错误或缺陷。",
|
||||
"3.使用者在使用本项目时必须严格遵守 GNU General Public License v3.0 的要求,并在适当的地方注明使用了 GNU General Public License v3.0 的代码。",
|
||||
"4.使用者在任何情况下均不得将本项目的作者、贡献者或其他相关方与使用者的使用行为联系起来,或要求其对使用者使用本项目所产生的任何损失或损害负责。",
|
||||
"5.使用者在使用本项目的代码和功能时,必须自行研究相关法律法规,并确保其使用行为合法合规。任何因违反法律法规而导致的法律责任和风险,均由使用者自行承担。",
|
||||
"6.本项目的作者不会提供 XHS-Downloader 项目的付费版本,也不会提供与 XHS-Downloader 项目相关的任何商业服务。",
|
||||
"7.基于本项目进行的任何二次开发、修改或编译的程序与原创作者无关,原创作者不承担与二次开发行为或其结果相关的任何责任,使用者应自行对因"
|
||||
"二次开发可能带来的各种情况负全部责任。",
|
||||
"",
|
||||
"在使用本项目的代码和功能之前,请您认真考虑并接受以上免责声明。如果您对上述声明有任何疑问或不同意,请不要使用本项目的代码和功能。如果"
|
||||
"您使用了本项目的代码和功能,则视为您已完全理解并接受上述免责声明,并自愿承担使用本项目的一切风险和后果。",
|
||||
"",
|
||||
">" * 50,
|
||||
)
|
||||
|
||||
download_link_error: str = "提取作品文件下载地址失败!"
|
||||
extract_link_failure: str = "提取小红书作品链接失败!"
|
||||
invalid_link: str = "未输入任何小红书作品链接!"
|
||||
download_failure: str = "下载小红书作品文件失败!"
|
||||
check_update_notification: str = "正在检查新版本,请稍等..."
|
||||
development_version_update: str = "当前版本为开发版, 可更新至正式版!"
|
||||
latest_development_version: str = "当前已是最新开发版!"
|
||||
latest_official_version: str = "当前已是最新正式版!"
|
||||
check_update_failure: str = "检测新版本失败!"
|
||||
|
||||
open_source_protocol: str = "开源协议:"
|
||||
project_address: str = "项目地址:"
|
||||
input_box_title: str = "请输入小红书图文/视频作品链接:"
|
||||
input_prompt: str = "多个链接之间使用空格分隔"
|
||||
download_button: str = "下载无水印作品文件"
|
||||
paste_button: str = "读取剪贴板"
|
||||
reset_button: str = "清空输入框"
|
||||
|
||||
exit_program: str = "退出程序"
|
||||
check_updates: str = "检查更新"
|
||||
get_script: str = "获取脚本"
|
||||
settings: str = "程序设置"
|
||||
|
||||
work_path: str = "工作路径:"
|
||||
folder_name: str = "文件夹名称:"
|
||||
user_agent: str = "User-Agent:"
|
||||
cookie: str = "Cookie:"
|
||||
proxy: str = "网络代理:"
|
||||
timeout: str = "请求超时限制:"
|
||||
chunk: str = "下载数据块大小:"
|
||||
max_retry: str = "最大重试次数:"
|
||||
record_data: str = "记录作品数据"
|
||||
image_format: str = "图片下载格式"
|
||||
folder_mode: str = "文件夹归档模式"
|
||||
language: str = "程序语言"
|
||||
server: str = "启动本地服务器"
|
||||
|
||||
work_path_placeholder: str = "程序根路径"
|
||||
user_agent_placeholder: str = "默认 UA"
|
||||
cookie_placeholder_true: str = "小红书网页版 Cookie,无需登录,参数已设置"
|
||||
cookie_placeholder_false: str = "小红书网页版 Cookie,无需登录,参数未设置"
|
||||
proxy_placeholder: str = "无代理"
|
||||
|
||||
settings_title: str = "程序设置"
|
||||
save_button: str = "保存配置"
|
||||
abandon_button: str = "放弃更改"
|
||||
|
||||
processing: str = "程序处理中..."
|
||||
|
||||
monitor_mode: str = "已启动监听剪贴板模式"
|
||||
monitor_text: str = "程序会自动读取并提取剪贴板中的小红书作品链接,并自动下载链接对应的作品文件,如需关闭,请点击关闭按钮,或者向剪贴板写入 “close” 文本!"
|
||||
close_monitor: str = "退出监听剪贴板模式"
|
||||
|
||||
record_title: str = "请输入待删除的小红书作品链接或作品 ID:"
|
||||
record_placeholder: str = "支持输入作品 ID 或包含作品 ID 的作品链接,多个链接或 ID 之间使用空格分隔"
|
||||
record_enter_button: str = "删除指定作品 ID"
|
||||
record_close_button: str = "返回"
|
||||
|
||||
@staticmethod
|
||||
def request_error(url: str) -> str:
|
||||
return f"网络异常,请求 {url} 失败!"
|
||||
|
||||
@staticmethod
|
||||
def skip_download(name: str) -> str:
|
||||
return f"{name} 文件已存在,跳过下载!"
|
||||
|
||||
@staticmethod
|
||||
def download_success(name: str) -> str:
|
||||
return f"{name} 下载成功!"
|
||||
|
||||
@staticmethod
|
||||
def download_error(name: str) -> str:
|
||||
return f"网络异常,{name} 下载失败!"
|
||||
|
||||
@staticmethod
|
||||
def pending_processing(num: int) -> str:
|
||||
return f"共 {num} 个小红书作品待处理..."
|
||||
|
||||
@staticmethod
|
||||
def start_processing(url: str) -> str:
|
||||
return f"开始处理作品:{url}"
|
||||
|
||||
@staticmethod
|
||||
def get_data_failure(url: str) -> str:
|
||||
return f"{url} 获取数据失败!"
|
||||
|
||||
@staticmethod
|
||||
def extract_data_failure(url: str) -> str:
|
||||
return f"{url} 提取数据失败!"
|
||||
|
||||
@staticmethod
|
||||
def processing_completed(url: str) -> str:
|
||||
return f"作品处理完成:{url}"
|
||||
|
||||
@staticmethod
|
||||
def official_version_update(major: int, minor: int) -> str:
|
||||
return f"检测到新版本:{major}.{minor}"
|
||||
|
||||
@staticmethod
|
||||
def exist_record(id_: str) -> str:
|
||||
return f"作品 {id_} 存在下载记录,跳过下载!"
|
||||
@@ -1,147 +0,0 @@
|
||||
from .chinese import Chinese
|
||||
|
||||
__all__ = ["English"]
|
||||
|
||||
|
||||
class English(Chinese):
|
||||
code: str = "en-GB"
|
||||
disclaimer: tuple[str] = (
|
||||
"Disclaimer about XHS-Downloader:",
|
||||
"",
|
||||
"1. The user decides on their own use of this project and assumes all risks. The author is not responsible "
|
||||
"for any losses, liabilities, or risks incurred by the user in using this project.",
|
||||
"2. The code and features provided by the author of this project are developed based on existing knowledge "
|
||||
"and technology. The author strives to ensure the correctness and security of the code but does not guarantee "
|
||||
"that the code is entirely free of errors or defects.",
|
||||
"3. The user must strictly adhere to the requirements of the GNU General Public License v3.0 when using this "
|
||||
"project and appropriately acknowledge the use of code licensed under the GNU General Public License v3.0.",
|
||||
"4. Under no circumstances may the user associate the author, contributors, or other relevant parties of this "
|
||||
"project with the user's actions, nor demand them to be held responsible for any losses or damages incurred "
|
||||
"by the user in using this project.",
|
||||
"5. The user must independently research relevant laws and regulations when using the code and features of "
|
||||
"this project, ensuring that their use is legal and compliant. Any legal responsibilities and risks arising "
|
||||
"from violations of laws and regulations are the sole responsibility of the user.",
|
||||
"6. The author of this project will not offer a paid version of the XHS-Downloader project and will not "
|
||||
"provide any commercial services related to the XHS-Downloader project.",
|
||||
"7. Any secondary development, modification, or compilation of programs based on this project is not "
|
||||
"associated with the original author. The original author is not responsible for any consequences related to "
|
||||
"secondary development actions or their results. The user is solely responsible for all situations that may "
|
||||
"arise from secondary development.",
|
||||
"",
|
||||
"Before using the code and features of this project, please carefully consider and accept the above "
|
||||
"disclaimers. If you have any questions or do not agree with the statements above, please refrain from using "
|
||||
"the code and features of this project. If you proceed to use the code and features of this project, "
|
||||
"it will be considered that you fully understand and accept the disclaimers mentioned above, and willingly "
|
||||
"assume all risks and consequences associated with using this project.",
|
||||
"",
|
||||
">" * 50,
|
||||
)
|
||||
|
||||
download_link_error: str = "Failed to extract the download address for the Xiaohongshu works files!"
|
||||
extract_link_failure: str = "Failed to extract the links for Xiaohongshu works!"
|
||||
invalid_link: str = "No Xiaohongshu works links provided!"
|
||||
download_failure: str = "Failed to download the Xiaohongshu works files!"
|
||||
check_update_notification: str = "Checking for new version, please wait..."
|
||||
development_version_update: str = (
|
||||
"The current version is a development version, and can be updated to the "
|
||||
"official version!")
|
||||
latest_development_version: str = "You are already using the latest development version!"
|
||||
latest_official_version: str = "You are already using the latest official version!"
|
||||
check_update_failure: str = "Failed to check for a new version!"
|
||||
|
||||
open_source_protocol: str = "Open Source License:"
|
||||
project_address: str = "Project Address:"
|
||||
input_box_title: str = "Please enter the link to the Xiaohongshu image/text or video works:"
|
||||
input_prompt: str = "Separate multiple links with spaces"
|
||||
download_button: str = "Download images/video files"
|
||||
paste_button: str = "Read the clipboard"
|
||||
reset_button: str = "Clear the input box"
|
||||
|
||||
exit_program: str = "Exit the program"
|
||||
check_updates: str = "Check for updates"
|
||||
get_script: str = "Get the script"
|
||||
settings: str = "Settings"
|
||||
|
||||
work_path: str = "Work path:"
|
||||
folder_name: str = "Folder name:"
|
||||
user_agent: str = "User-Agent:"
|
||||
cookie: str = "Cookie:"
|
||||
proxy: str = "Network proxy:"
|
||||
timeout: str = "Request timeout limit:"
|
||||
chunk: str = "Download data block size:"
|
||||
max_retry: str = "Maximum retry attempts:"
|
||||
record_data: str = "Record works data"
|
||||
image_format: str = "Image download format"
|
||||
folder_mode: str = "Folder archiving mode"
|
||||
language: str = "Program language"
|
||||
server: str = "Start local server"
|
||||
|
||||
work_path_placeholder: str = "Program root path"
|
||||
user_agent_placeholder: str = "Default UA"
|
||||
cookie_placeholder_true: str = "Xiaohongshu web version cookie, no login required, parameters have been set"
|
||||
cookie_placeholder_false: str = "Xiaohongshu web version cookie, no login required, parameters not set"
|
||||
proxy_placeholder: str = "No proxy"
|
||||
|
||||
settings_title: str = "Settings"
|
||||
save_button: str = "Save configuration"
|
||||
abandon_button: str = "Discard changes"
|
||||
|
||||
processing: str = "Processing..."
|
||||
|
||||
monitor_mode: str = "Currently in monitoring clipboard mode"
|
||||
monitor_text: str = (
|
||||
"The program will automatically read and extract the link to Xiaohongshu's works from the "
|
||||
"clipboard, and automatically download the corresponding work file. If you want to close it, "
|
||||
"please click the close button or write the \"close\" text to the clipboard!")
|
||||
close_monitor: str = "Exit monitoring clipboard mode"
|
||||
|
||||
record_title: str = "Please enter the link or ID of the Xiaohongshu work to be deleted:"
|
||||
record_placeholder: str = (
|
||||
"Support input of works ID or links containing work ID, with multiple links or IDs "
|
||||
"separated by spaces")
|
||||
record_enter_button: str = "Delete specified works ID"
|
||||
record_close_button: str = "return"
|
||||
|
||||
@staticmethod
|
||||
def request_error(url: str) -> str:
|
||||
return f"Network error, failed to access {url}!"
|
||||
|
||||
@staticmethod
|
||||
def skip_download(name: str) -> str:
|
||||
return f"{name} already exists, skipping download!"
|
||||
|
||||
@staticmethod
|
||||
def download_success(name: str) -> str:
|
||||
return f"{name} download successful!"
|
||||
|
||||
@staticmethod
|
||||
def download_error(name: str) -> str:
|
||||
return f"Network error, {name} download failed!"
|
||||
|
||||
@staticmethod
|
||||
def pending_processing(num: int) -> str:
|
||||
return f"{num} works from Xiaohongshu are awaiting processing..."
|
||||
|
||||
@staticmethod
|
||||
def start_processing(url: str) -> str:
|
||||
return f"Start processing the works: {url}"
|
||||
|
||||
@staticmethod
|
||||
def get_data_failure(url: str) -> str:
|
||||
return f"{url} failed to retrieve data!"
|
||||
|
||||
@staticmethod
|
||||
def extract_data_failure(url: str) -> str:
|
||||
return f"{url} failed to extract data!"
|
||||
|
||||
@staticmethod
|
||||
def processing_completed(url: str) -> str:
|
||||
return f"works processing completed: {url}"
|
||||
|
||||
@staticmethod
|
||||
def official_version_update(major: int, minor: int) -> str:
|
||||
return f"New version detected: {major}.{minor}"
|
||||
|
||||
@staticmethod
|
||||
def exist_record(id_: str) -> str:
|
||||
return f"works {id_} has a download record, skipping download!"
|
||||
@@ -1,9 +1,10 @@
|
||||
// ==UserScript==
|
||||
// @name XHS-Downloader
|
||||
// @namespace https://github.com/JoeanAmier/XHS-Downloader
|
||||
// @version 1.4.1
|
||||
// @version 1.4.3
|
||||
// @description 提取小红书作品/用户链接,下载小红书无水印图文/视频作品文件
|
||||
// @author JoeanAmier
|
||||
// @match http*://xhslink.com/*
|
||||
// @match http*://www.xiaohongshu.com/explore*
|
||||
// @match http*://www.xiaohongshu.com/user/profile/*
|
||||
// @match http*://www.xiaohongshu.com/search_result*
|
||||
@@ -140,7 +141,7 @@
|
||||
|
||||
const generateImageUrl = note => {
|
||||
let images = note.imageList;
|
||||
const regex = /\/([^\/]+?)!/;
|
||||
const regex = /http:\/\/sns-webpic-qc\.xhscdn.com\/\d+\/[0-9a-z]+\/(\S+)!/;
|
||||
let urls = [];
|
||||
try {
|
||||
images.forEach((item) => {
|
||||
@@ -202,6 +203,13 @@
|
||||
try {
|
||||
// 使用 fetch 获取文件数据
|
||||
let response = await fetch(link);
|
||||
|
||||
// 检查响应状态码
|
||||
if (!response.ok) {
|
||||
console.error(`请求失败,状态码: ${response.status}`, response.status);
|
||||
return false
|
||||
}
|
||||
|
||||
let blob = await response.blob();
|
||||
|
||||
// 创建 Blob 对象的 URL
|
||||
@@ -217,8 +225,11 @@
|
||||
|
||||
// 清理临时链接元素
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(`下载失败 (${filename}):`, error);
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,12 +241,18 @@
|
||||
};
|
||||
|
||||
const downloadVideo = async (url, name) => {
|
||||
await downloadFile(url, `${name}.mp4`);
|
||||
if (!await downloadFile(url, `${name}.mp4`)) {
|
||||
abnormal();
|
||||
}
|
||||
};
|
||||
|
||||
const downloadImage = async (urls, name) => {
|
||||
let result = [];
|
||||
for (const [index, url] of urls.entries()) {
|
||||
await downloadFile(url, `${name}_${index + 1}.png`);
|
||||
result.push(await downloadFile(url, `${name}_${index + 1}.png`));
|
||||
}
|
||||
if (!result.every(item => item === true)) {
|
||||
abnormal();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 65 KiB |