Manifest V3:修改网络请求的Unsafe Header

在使用Manifest v3规范编写浏览器插件 PT-Depiler 时,我们发现插件在获取部分站点信息时会失败。
进一步 debug 可知,这些站点启用了跨站请求伪造 (CSRF) 保护,即通过验证 Origin 请求头,并拒绝相关插件的请求。

然而 Origin 请求头并不能由插件直接控制,当插件在从 offscreen 或者 service worker 等环境下发起 fetchxmlhttprequest 请求时,浏览器会自动将该请求的 Origin 标头设置为你的扩展程序的来源 (origin),即: Origin: chrome-extension://[YOUR_EXTENSION_ID]

这一请求头甚至都不能覆写,如果尝试在 fetch 或者 axios (指 XMLHttpRequest ) 的请求参数中手动设置 Origin,浏览器在多数情况下会静默忽略,或者直接报错。

Manifest v2 下的处理

在 Manifest v2 环境下,我们可以使用 webRequest + webRequestBlocking 权限给予 block 功能,利用类似下面代码在浏览器发送请求头之前实现对覆盖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const gDummyHeaderPrefix = "Overwrite-";
chrome.webRequest.onBeforeSendHeaders.addListener(
(details) => {
let headers = [];
if (details.requestHeaders) {
headers = details.requestHeaders.map((header) => {
if (header.name.startsWith(gDummyHeaderPrefix)) {
const modifiedName = header.name.replace(gDummyHeaderPrefix, "");
return { name: modifiedName, value: header.value };
} else {
return { name: header.name, value: header.value };
}
});
}
return { requestHeaders: headers };
},
{
urls: ["<all_urls>"]
},
["requestHeaders", "blocking", "extraHeaders"]
);

这段代码注册了一个监听器拦截所有网址的网络请求,其中 blocking 参数表示这是「阻塞式监听」—— 浏览器会暂停发送请求,等待监听器处理完请求头后,再继续执行发送操作(确保修改后的请求头能被使用);而 extraHeaders 参数允许访问和修改 Chrome 默认保护的「敏感请求头」 (即我们所需要的 Origin、Referer 等请求头)。

Manifest v3 下的处理

然而在 Manifest v3 下,我们并不能使用 webRequestBlocking 权限,MV3的 文档 中明确指出了这一权限仅适用于通过策略安装的插件,而对于通过商店或者解压缩等方式安装的插件并不适用。

Note: As of Manifest V3, the “webRequestBlocking” permission is no longer available for most extensions. Consider “declarativeNetRequest”, which enables use the declarativeNetRequest API. Aside from “webRequestBlocking”, the webRequest API is unchanged and available for normal use. Policy installed extensions can continue to use “webRequestBlocking”.

在进一步了解 declarativeNetRequest API时,我发现 Google Group chromium-extensions 中提供了一种简易绕过方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
chrome.runtime.onInstalled.addListener(async () => {
const rules = [{
id: 1,
action: {
type: 'modifyHeaders',
requestHeaders: [{ header: 'Origin', operation: 'remove' }],
},
condition: {
initiatorDomains: [chrome.runtime.id],
requestMethods: ['post'],
resourceTypes: ['xmlhttprequest'],
},
}];
await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: rules.map(r => r.id),
addRules: rules,
});
});

上面那段代码提供了一个很简单的实现,直接将插件发出的 post 请求中的 Origin 都移除。这个办法很好,稍微拓展下,比如在 requestMethods 中添加 get 或其他请求方法,即可以覆盖更多的应用场景。

但是,我们有个问题就是在 PT-Depiler 设计思路中,插件主体和站点请求 ( pkg/site 包的具体实现) 是尽可能解耦分离的,同时具体站点请求可能对 Origin 等浏览器保护头的改动(或删除)需求不一致,需要一种更为合理的方式来解决。

鉴于我们主要使用 axios 来实现网络请求,所以主要解决思路是 使用 axios 提供的拦截器在请求发送前生成DNR规则,在请求完成时删除对应的DNR规则

  1. 构造 Axios 拓展
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
import type { AxiosInstance } from "axios";
import { sendMessage } from "@/messages.ts";

export const unsafeHeaders: { [key: string]: boolean } = {
"user-agent": true,
cookie: true,
"accept-charset": true,
"accept-encoding": true,
"access-control-request-headers": true,
"access-control-request-method": true,
connection: true,
"content-length": true,
date: true,
dnt: true,
expect: true,
"feature-policy": true,
host: true,
"keep-alive": true,
origin: true,
referer: true,
te: true,
trailer: true,
"transfer-encoding": true,
upgrade: true,
via: true,
};

interface AxiosAllowUnsafeHeaderInstance extends AxiosInstance {
defaults: AxiosInstance["defaults"] & {
allowUnsafeHeader: boolean;
};
}

export function setupReplaceUnsafeHeader(axios: AxiosInstance): AxiosAllowUnsafeHeaderInstance {
const axiosAllowUnsafeHeaderInstance = axios as AxiosAllowUnsafeHeaderInstance;

if (axiosAllowUnsafeHeaderInstance.defaults.allowUnsafeHeader) {
console.debug("setupReplaceUnsafeHeader() should be called only once");
return axiosAllowUnsafeHeaderInstance;
}
axiosAllowUnsafeHeaderInstance.defaults.allowUnsafeHeader = true;

// Add a request interceptor
axiosAllowUnsafeHeaderInstance.interceptors.request.use(async function (config) {
if (config.headers) {
// 准备扔给 chrome.declarativeNetRequest 的请求头
const requestHeaders = [] as chrome.declarativeNetRequest.ModifyHeaderInfo[];

for (const [key, value] of config.headers) {
const lowerKey = key.toLowerCase();
if (unsafeHeaders[lowerKey] || lowerKey.startsWith("sec-") || lowerKey.startsWith("proxy-")) {
requestHeaders.push({
header: key,
operation: "set" as chrome.declarativeNetRequest.HeaderOperation.SET,
value: String(value),
});
config.headers.delete(key);
}
}

if (requestHeaders.length > 0) {
// 生成一个随机的请求 ID,与 chrome.declarativeNetRequest 匹配
const dummyHeaderRequestId = Math.floor(Math.random() * 1e7);
(config as any).dummyHeaderRequestId = dummyHeaderRequestId;

const requestUrl = axios.getUri({ baseURL: config.baseURL, url: config.url });

const rule = {
id: dummyHeaderRequestId,
priority: 1,
action: {
type: "modifyHeaders",
requestHeaders,
},
condition: {
urlFilter: requestUrl,
resourceTypes: ["xmlhttprequest" as chrome.declarativeNetRequest.ResourceType.XMLHTTPREQUEST],
requestMethods: [(config.method || "GET").toLowerCase() as chrome.declarativeNetRequest.RequestMethod],
},
} as chrome.declarativeNetRequest.Rule;

await sendMessage("updateDNRSessionRules", { rule });
}
}

return config;
});

function removeDummyHeaderRequestId(config: any) {
if (config?.config?.dummyHeaderRequestId) {
// noinspection JSIgnoredPromiseFromCall
sendMessage("removeDNRSessionRuleById", config.config.dummyHeaderRequestId);
}
}

// 请求完成后,根据 dummyHeaderRequestId 自动删除 DNR 规则
axiosAllowUnsafeHeaderInstance.interceptors.response.use(
function (response) {
removeDummyHeaderRequestId(response);
return response;
},
function (error) {
removeDummyHeaderRequestId(error);
return Promise.reject(error);
},
);

return axiosAllowUnsafeHeaderInstance;
}

因为此处的 axios 主要在 offscreen 或者 contentScript 等环境中使用,不一定能访问 chrome.declarativeNetRequest 这个 API,所以要使用 sendMessage

  1. 在 server worker / background 中抛出 DNR 相关处理方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { onMessage } from "@/messages.ts";

onMessage("updateDNRSessionRules", async ({ data: { rule, extOnly = true } }) => {
// 不影响其他非本扩展的网络请求规则
if (extOnly) {
const tabs = await chrome.tabs.query({});
const excludedTabIds: number[] = [];
tabs.forEach((tab) => {
if (tab.id && tab.url) {
excludedTabIds.push(tab.id);
}
});
rule.condition.excludedTabIds ??= excludedTabIds;
}

return await chrome.declarativeNetRequest.updateSessionRules({
removeRuleIds: [rule.id],
addRules: [rule],
});
});

onMessage("removeDNRSessionRuleById", async ({ data: ruleId }) => {
sendMessage("logger", { msg: `Remove DNR session rule by ID: ${ruleId}` }).catch();
return await chrome.declarativeNetRequest.updateSessionRules({
removeRuleIds: [ruleId],
});
});

此处,我们使用 updateSessionRules 而不是 updateDynamicRules ,因为 相关请求是一次性的,我们没必要保持。

Dynamic rules persist across browser sessions and extension upgrades.
Session rules are cleared when the browser shuts down and when a new version of the extension is installed.

如此,我们只要在使用 axios 前先应用这一插件,就可以让相关请求能够实现改写 Origin 的功能

1
2
3
4
5
6
7
import axiosRaw from "axios";
import { setupReplaceUnsafeHeader } from "~/extends/axios/replaceUnsafeHeader.ts";

const axios = setupReplaceUnsafeHeader(axiosRaw);
const resp = await axios.get('https://httpbin.io/headers', { headers: { 'Origin': 'https://www.google.com' } })

console.log(resp.data.headers.Origin) // ['https://www.google.com']

记一个很有意思的PWD问题

很久很久以前,我写了一个脚本来加密备份些下载好的东西,并使用了crontab定期运行。其代码类似如下

1
2
3
4
5
6
7
8
9
#!/bin/bash

PWD="xxxxxxxxx"
FOLDER="/data/xxxxxxx/"
FILE=/tmp/xxxxxxxx_`date +%Y%m%d`.zip

cd ${FOLDER}

zip -r ${FILE} ./ -P ${PWD} -m

从脚本上看倒是没啥问题,但在我这次想要从里面提取文件时,却发现使用脚本中的密码出错。百思不得其解的我尝试在终端中输入了

1
2
3
4
5
6
root@home:~# PWD="xxxxxxxxx"
root@home:xxxxxxxxx# echo ${PWD}
xxxxxxxxx
root@home:xxxxxxxxx# cd /
root@home:/# echo ${PWD}
/

结果神奇的事情出现了,对 ${PWD} 赋值的行为改变了当前显示的文件夹名称,并且在cd之后, ${PWD} 的值自动被重写为当前的目录值了,而不是我最初指定的那个值了。一番搜索后才知道,原来 ${PWD} 是一个环境变量,和 pwd 命令几乎类似,指向了当前的工作目录。而我在脚本中,先对PWD变量进行赋值,随后使用cd切换了工作目录,导致 $PWD 的值在切换后实际和 ${FOLDER} 值相同。

NexusPHP优化(4) Torrent表拆分及独立搜索引擎

在很久很久以前,就有位sysop和我说到:”NexusPHP的压力,六成在种子搜索,四成在Tracker“,我记得ta还和我说过:”NexusPHP的torrents表是张很热很热的表“(也可能是另一站的sysop),于是ta把他们站的搜索引擎切换到了Xunsearch上,果然没有再出现过压力过大的问题。

对上面两句话我的粗浅认知:

  1. tracker在每次seeder, leechers, time_completed 变动时都会更新torrents表,更新很频繁
  2. 种子搜索使用的是 MySQL 的 LIKE %...% 语句,特别是关键词多的时候,如搜索 I Love Flowers 会被最终拼成 WHERE (name LIKE "%Love%" or small_descr LIKE "%Love") and (name LIKE "%Flowers%" or small_descr LIKE "%Flowers%")。但MySQL的该语句不能走索引(走全文索引的是 MATCH AGAINST),所以会触发全表扫描

所以我也信了ta的话,在我站频繁出现5xx错误后,我同样把优化的方向改为了 从Mysql的 LIKE %...% 语句中切换到更加专业的全文搜索引擎中。结果很顺利,站点又恢复了之前的流畅。

注:

  1. 本文不仅仅有全文搜索引擎的替换说明,还涉及对NPHP他那屎山一般的 torrents.php 实现进行优化;因此,全部实现示例会拆成多个commit+1个整体pr来尽可能展示本站的更新。
  2. 本文在行文以及对应commit中的实现与OurBits站点的实现并不一致,仅作思路展示。

豆瓣搜索 `__DATA__` 对象破解

这些年我一直跟着维护Pt-Gen以及PTPP、豆瓣搜索大师等公开项目。然而19年起,豆瓣逐渐关闭公开API (豆瓣疑下线所有公开 API),从现有豆瓣APP的 frodo 接口请求相关数据,所需要构造的参数过于麻烦。

所以在这段时间,我们一直使用 https://movie.douban.com/j/subject_suggest 接口来实现搜索功能作为过渡。这个接口构造简单,而且返回的数据直接为JSON格式,但同样存在返回的数据较为简单的问题,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[
{
"episode": "",
"img": "https://img2.doubanio.com/view/photo/s_ratio_poster/public/p480747492.webp",
"title": "肖申克的救赎",
"url": "https://movie.douban.com/subject/1292052/?suggest=%E8%82%96%E7%94%B3%E5%85%8B",
"type": "movie",
"year": "1994",
"sub_title": "The Shawshank Redemption",
"id": "1292052"
},
{
"episode": "",
"img": "https://img1.doubanio.com/f/movie/b6dc761f5e4cf04032faa969826986efbecd54bb/pics/movie/movie_default_small.png",
"title": "肖申克的救赎:主演访谈",
"url": "https://movie.douban.com/subject/35278770/?suggest=%E8%82%96%E7%94%B3%E5%85%8B",
"type": "movie",
"year": "2004",
"sub_title": "The Shawshank Redemption: Cast Interviews",
"id": "35278770"
}
]

只返回了img, title, url, type, id(虽然基本够用….)。这主要是因为该接口只是用来搜索建议的,而真正搜索界面的结果隐藏在了subject_search页面的 __DATA__ 对象中。所以我们需要用另外一种方法来破解该对象。

我最早找到的文章来自掘金的这篇 豆瓣读书搜索页的window.__DATA__的解密 ,然而作者的思路是直接扒取豆瓣自带的JS文件(这当然没有问题)。然而作者讲的挺玄乎的: 总之很复杂,这个只能意会不能言传,篇幅有限,也不可能全部一个一个扣出来并和你说怎么改。

正当我以为没有办法,只能复制这位作者成果的时候(毕竟顶层函数有了,而且我也是在 JavaScript 环境中使用,不需要在Python中调用),我检索到了另外一个项目 dli98/Spider,文中作者详细介绍了 window.__data__ 参数破解过程是使用 base64 + xxHash + rc4 + bplist 一整套的思路,并指出 bplist 处理流程有暗改的情况。

但这位作者的结果其实并不对,少了最后一步的映射调整 (见 怎么获取豆瓣评分和投票数的顺序 · Issue #2 · dli98/Spider),所以得到的结果并不对。

所以就干脆直接写个 npm 库算了,之后工程中也能使用。于是你现在可以使用 npm i douban-search-crack来进行安装,其实例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
import axios from 'axios';
import decryptDoubanData, { extractDataFromPage } from 'douban-search-crack';

const { data: doubanSearchPage } = await axios.get('https://search.douban.com/movie/subject_search', {
params: {
search_text: '肖申克的救赎',
cat: 1002
},
responseType: 'text'
})

const encryptDoubanData = extractDataFromPage(doubanSearchPage)
console.log(decryptDoubanData(encryptDoubanData))

函数主流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export default function decryptDoubanData(dataRawString: string): searchData {
// 将网页字符串解析为base64
const dataRaw = Buffer.from(dataRawString, 'base64')

// 从base64中提取secKey,并整理生成需要解密的数据
const i = 16
const s = Math.max(Math.floor((dataRaw.length - 2 * 16) / 3), 0)
const u = dataRaw.slice(s, s + i)

const secKey = XXH.h64(u, 41405).toString(16)
const encryptData = Buffer.concat([dataRaw.slice(0, s), dataRaw.slice(s + i)])

// rc4解密
const cipher = new RC4(secKey);
const decryptData = cipher.update(encryptData);

// BpList解密
const bpList = parseBPlist(decryptData)

// 对BpList的结果进行修正并返回
return fixture.parse(bpList)
}

其中 xxhash 部分直接用库;rc4 因为过于简单,所以方法也直接内置;bplist解密因为豆瓣的暗改,所以不能直接用库,所以也是将库文件修改后内置的(原来想学着用monkeypatch的,然而patch发现还不如直接内置)。

此外,bpList部分,对比库原实现,除调换了objType= 4,5,6的解析方式,还删除了 对库 bigInt 的依赖,以及对header的检查。

而 fixture 模块,主要因为 BpList 返回的值主要为一个list,里面的Object字段基本如下:

1
2
3
{k: [ 40, 27, 35, 50, 52, 44 ] }
{k: [ '', [] ] , z: [ 44, 55 ] }
{k: [ '', [] , {j: 55} ], z: [ 44, 66, 88 ]}

所以我们需要进行修正,其中各数字均表示其在原list中的位置。通过对比 sergiojune 扒出来的代码,形成了矫正的步骤,具体可见 utils/fixture.ts 文件。

那么,自此,你就可以使用 douban-search-crack来在node以及browser环境中来破解豆瓣搜索的DATA数据。与直接扒网页js相比,如果你在node环境中,那么总文件大小 仅 16.0 KB (16,452 字节)。而如果你要在Browser环境中使用,那么也可以直接使用打包好的 dist/bundle.js 文件,其大小为 47.0 KB (48,147 字节),与直接扒拉js的实现大小基本相同。

PT-GEN 关闭说明

你可以使用以下工具作为替代:

  1. BFDZ提供的 https://www.bfdz.ink/tools/ptgen/, 其后端是 pt-gen-cfworker。你也可以参照blog中已有说明,注册Cloudflare并搭建,并配置 APIKEY 环境变量,以防止被滥用。
  2. 豆瓣资源下载大师:1秒搞定豆瓣电影|音乐|图书下载,并在右上角脚本设置页面,打开 电影简介生成 功能项。
  3. 电影信息查询脚本

PT-GEN 是本人于2017年10月上线的一个简介生成服务,主要为PT等需要电影、动漫等简介网站提供格式化简介的生成。整个的编写过程基本也见证了 本人青春的大学岁月到如今步入社会 ,以及 编程 这项个人兴趣能力的提高。
image-20210808200043429.png

虽然已经记不清楚当初为什么写这个项目了,但上线以来一直是本人义务维护,并提供开源代码。(然而国内的开源氛围并不良好2333)

此外,上线以来也受过多次暴力请求,此次下线的原因也一样——长达一个月的暴力请求,而且至今未停止

从7月初起,该请求源就开始使用脚本和不同代理ip批量请求节点的搜索接口,本人于7月20日在cloudflare处开盾并设置防火墙规则和ban ip后,依然没有停止脚本请求。(虽然至此之后,正常简介生成基本没有受影响)

image-20210808201521176.png

鉴于本人暂无精力维护已有项目,至即日起,停止 https://api.rhilip.info/tool/movieinfo/gen 地址的开放,关闭 pt-gen-cfworker的两个demo地址: https://ptgen.rhilip.info/https://ptgen.rhilip.workers.dev ,之后访问这个地址会直接遇到 CF 的相关报错。

最后感谢: Issacc、yezi1000、BFDZ 等友人的代码协助以及OurBits在整个项目的支持。

国自然结题报告下载脚本

见下,原下载链接 https://greasyfork.org/zh-CN/scripts/421052-nsfc-conclusion-downloader

1
2
原来这里应该有些东西的。。。。
但是现在什么都没有

虽然脚本在 Greasyfork 上处于被删除状态,但是你仍然可以通过构造链接的方法,成功安装原有脚本

Bittorrent v2对比及实践相关

前段时间(9月7日),libtorrent宣布其2.0版本开始支持 BEP 52 The BitTorrent Protocol Specification v2 的相关协议(BitTorrent v2 - libtorrent.org)。

之前我在写RidPT的时候便翻过这个定稿于2017年的标准(历史悠久),但当时并没有客户端或Tracker对该标准有实现(较长时间内可能也不会有Tracker或者下载器实现),匆匆瞄了一眼便搁置了。如今重新捡起,看看对应标准和Tracker侧如何兼容。

全文总结: Bittorrent v2并不像是为了Private Tracker设计的。其中一些诸如节省metadata体积的方法、基于文件而不是字节块的哈希方法等,在magnet、DHT等协议中或许能体现其用途,但对于PT来说,可能作用的体现并不明显。

一、Spec更改项对比

此处仅列出我比较感兴趣的几个协议更改项对比,如有需要请翻阅 The BitTorrent Protocol SpecificationThe BitTorrent Protocol Specification v2 进行更进一步的了解。

1. hash算法从SHA-1变更为SHA-256

v2协议中将哈希算法从原来SHA-1变更为SHA-256,这一改变不仅体现在了对于单一文件区块哈希中,同样还体现在了对于$->info的整体哈希中。这么变更的理由在于避免SHA-1的哈希碰撞(参见Announcing the first SHA1 collision)。这与git不同,git同样使用SHA-1作为哈希算法,但是commit的SHA-1值分布在不同仓库中,能很大程度上避免SHA-1值被碰撞。而bittorrent的相关种子infohash值是全网空间的,特别在magnet协议中,是以 magnet:?xt=urn:btih:<sha1-info-hash>的形式进行构造的,所以在全网空间中,是可能存在哈希碰撞的情况。

然而SHA-256的结果是32字节,这与目前SHA-1的20字节不相同。这就意味着如果要向下兼容的话,需要将已有的32字节SHA-256截断成20字节。目前在libtorrent中的实现便是通过扩展escape_string方法,并将最后的info_hash截断成20字节。

1
2
3
// from https://github.com/arvidn/libtorrent/blob/ebe82ae569c23eb4fcd435e5c94e4763d4c8d4e1/src/http_tracker_connection.cpp#L110
url += "info_hash=";
url += escape_string({tracker_req().info_hash.data(), 20});

2. 对文件列表结构进行重构(变成文件树形式)

这个是我个人比较喜欢的一点改变,因为从文件列表转化为文件树,能大大减少原$->info->files[i]->path 过长以及重复信息过多的问题(但这并不一定意味着种子文件大小能够减少)。

在v1中,$->info-> files 的列表可能如下:

1
2
3
4
5
6
7
8
'files': [
{ 'length': 12323346, 'path': [ 'F' ] },
{ 'length': 2567, 'path': [ 'this is a very long directory name that ends up being duplicated a lot of times in v1 torrents', 'A' ] },
{ 'length': 14515845, 'path': [ 'this is a very long directory name that ends up being duplicated a lot of times in v1 torrents', 'B' ] },
{ 'length': 912052, 'path': [ 'this is a very long directory name that ends up being duplicated a lot of times in v1 torrents', 'C' ] },
{ 'length': 1330332, 'path': [ 'this is a very long directory name that ends up being duplicated a lot of times in v1 torrents', 'D' ] },
{ 'length': 2529209, 'path': [ 'this is a very long directory name that ends up being duplicated a lot of times in v1 torrents', 'E' ] }
],

这在文件数量特别多,或者文件目录嵌套过深时,特别容易造成最终打包出来的种子大小过大。(因为冗余信息过多)。而在v2标准中,将其改为使用文件树形式,并使用$->info->file tree存放。在file tree字典内,使用一个字典空键值表示最终文件,并为每个文件提供一个哈希检验值(额外的哈希检验值在某种程度上膨胀了种子体积)。

1
2
3
4
5
6
7
8
9
10
'file tree': {
'F': { '': { 'length': 12323346, 'pieces root': 'd1dca3b4a65568b6d62ef2f62d21fcdb676099797c8aa3e092aa0adcb9a9f6a5' } },
'this is a very long directory name that ends up being duplicated a lot of times in v1 torrents': {
'A': { '': { 'length': 2567, 'pieces root': 'f6e5b48ebc00d7c6351aafdec9a0fa40ab9c8effe8ac6cfb565df070d9532f70' } },
'B': { '': { 'length': 14515845, 'pieces root': '271d61e521401cfb332110aa472dae5f0d49209036eb394e5cf8a108f2d3fb03' } },
'C': { '': { 'length': 912052, 'pieces root': 'd66919d15e1d90ead86302c9a1ee9ef73b446be261d65b8d8d78c589ae04cdc0' } },
'D': { '': { 'length': 1330332, 'pieces root': '202e6b10310d5aae83261d8ee4459939715186cd9f43336f37ca5571ab4b9628' } },
'E': { '': { 'length': 2529209, 'pieces root': '9cc7c9c9319a80c807eeefb885dff5f49fe7bf5fba6a6fc3ffee5d5898eb5fdb' } }
}
},

而pieces root的计算方法为merkle hash trees,这也使得v2的种子在文件层次上是对齐的。其原理示例图如下:

merkle-hash-tree.png

此外,对于原v1的$->info->pieces项,v2种子中也不再提供,而是将其放置在$->piece layers中,与最终的infohash值脱钩。

piece layers项在v2种子中是必须存在的。单文件大小小于区块大小的文件可以不列入该字典中,故其可以为空字典。而其键为之前在文件pieces root项出现的值,而其值为该文件每一个piece length大小的区块拼接形成,在实质上与之前的$->info->pieces项相同。

值得注意的是 piece layers项的值只考虑有效区块(不考虑对齐区块),其原文如下:

1
Layer hashes which exclusively cover data beyond the end of file, i.e. are only needed to balance the tree, are omitted.

3. 对Bencode方法规定的补充

在v2中对原Bencode编解码相关规则进行了补充,主要是对于utf8的支持。原文如下:

1
2
3
Note that in the context of bencoding strings including dictionary keys are arbitrary byte sequences (uint8_t[]).

BEP authors are encouraged to use ASCII-compatible strings for dictionary keys and UTF-8 for human-readable data. Implementations must not rely on this.

二、种子制作测试

分别使用官网提供的工具bep_0052_torrent_creator.py以及Qbittorrent的使用同一区块大小(64kb)进行制种,观察种子结构,对比如下

多文件(文件夹)制种种子结构

一个典型的v1、v2-only、v2-compatibility 多文件种子结构分别如下(JSON格式):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// v1 torrent
{
"announce": "example.com",
"info"{
"files": [
{"length": 23456, "path": ["folder", "filename"]},
......
],
"name": "test",
"piece length": 65536,
"pieces": "<hex string>",
}
}

// v2-only torrent
{
"announce": "example.com",
"info"{
"files tree": {
"folder": {
"filename": {
"": {"length": 23456, "pieces root": "<hex string>"},
},
...
},
...
},
"meta version": 2,
"name": "test",
"piece length": 65536
},
"piece layers": {
"<hex string>": "<hex string>",
...
},
}

// v2-compatibility torrent
{
"announce": "example.com",
"info"{
"files": [
{"attr": "x", "length": 23456, "path": ["folder", "filename"]},
{"attr": "x", "length": 42080, "path": [".pad", "42080"]},
......
],
"files tree": {
"folder": {
"filename": {
"": {"attr": "x", "length": 23456, "pieces root": "<hex string>"},
},
...
},
...
},
"meta version": 2,
"name": "test",
"piece length": 65536,
"pieces": "<hex string>"
},
"piece layers": {
"<hex string>": "<hex string>",
...
},
}

单文件制种种子结构

而一个典型的v1、v2-only、v2-compatibility 单文件种子结构分别如下(JSON格式):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// v1 torrent
{
"announce": "example.com",
"info"{
"length": 23456,
"name": "test",
"piece length": 65536,
"pieces": "<hex string>",
}
}

// v2-only torrent
{
"announce": "example.com",
"info"{
"files tree": {
"filename": {
"": {"length": 23456, "pieces root": "<hex string>"},
},
...
},
"meta version": 2,
"name": "test",
"piece length": 65536
},
"piece layers": {
"<hex string>": "<hex string>",
...
},
}

// v2-compatibility torrent
{
"announce": "example.com",
"info"{
"files tree": {
"filename": {
"": {"length": 23456, "pieces root": "<hex string>"},
},
...
},
"length": 23456,
"meta version": 2,
"name": "filename",
"piece length": 65536,
"pieces": "<hex string>"
},
"piece layers": {
"<hex string>": "<hex string>",
...
},
}

不同区块大小的v2-compatibility种子结构

分别使用64 KiB(65535 bytes)和4 MiB(4194304 bytes)对同一个小文件(135857 bytes/132 KB)进行测试,比较种子结构,分别如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// piece length 65536
{
"announce": "http://example.com/announce",
"info": {
"file tree": {
"xxxxxxx.pdf": {
"": {
"length": 135857,
"pieces root": "<hex>C4 46 ED 2A D3 47 2A 2E 93 3C 78 69 EE 9C DE 53 93 F3 AC 15 A3 21 B4 9A 8F 2B 14 6D 26 08 F0 E1</hex>"
}
}
},
"length": 135857,
"meta version": 2,
"name": "xxxxxxx.pdf",
"piece length": 65536,
"pieces": "<hex>91 40 E3 4C BB 31 F4 BD 49 43 D3 E0 8B 54 61 F7 1E 98 A8 6A DB DB 54 5D CE DB 69 59 26 9D 3E 84 FD D1 37 C2 51 B4 20 44 82 A1 EF 0C 22 70 06 89 04 40 9A 92 86 52 9D 58 EE 6B 38 4A</hex>"
},
"piece layers": {
"<hex>C4 46 ED 2A D3 47 2A 2E 93 3C 78 69 EE 9C DE 53 93 F3 AC 15 A3 21 B4 9A 8F 2B 14 6D 26 08 F0 E1</hex>": "<hex>63 FA D1 23 4D 0F C5 59 13 B9 4E 08 A3 FD BB 1D 24 C0 2A 8C 02 94 0E A3 ED 20 F7 D0 5B D9 B3 3C 58 D1 06 F4 21 0D FC AA 85 97 13 6E 27 B0 07 D7 DB 56 3C 4A 8F 63 13 E2 E9 AC 4D D8 19 78 A2 57 05 F0 50 70 BA 9D BE 32 6B C4 6C 9A 07 A2 9D 07 E8 BD FE F3 B5 0A C3 C1 A0 74 E8 6B 02 33 2C CF</hex>"
}
}

// piece length 4194304
{
"announce": "http://example.com/announce",
"info": {
"file tree": {
"xxxxxxx.pdf": {
"": {
"length": 135857,
"pieces root": "<hex>C4 46 ED 2A D3 47 2A 2E 93 3C 78 69 EE 9C DE 53 93 F3 AC 15 A3 21 B4 9A 8F 2B 14 6D 26 08 F0 E1</hex>"
}
}
},
"length": 135857,
"meta version": 2,
"name": "xxxxxxx.pdf",
"piece length": 4194304,
"pieces": "<hex>48 A0 5A 29 00 BC 6D F4 5A AA 4D F3 96 BE 88 A5 A1 D1 13 39</hex>"
},
"piece layers": {}
}

可以看出其文件的 pieces root 项的值并没有随着做种区块大小的变动而改变。(这个功能优化或将给基于infohash以及文件大小名称的辅种方法提供新的思路)

此外,由于区块大小大于文件总大小,在4 MiB区块的种子中,其piece layers 项为空字典。这也满足协议相关定义(For each file in the file tree that is larger than the piece size it contains one string value.

同一文件夹制种测试

使用同一区块大小(64 KiB)对某一文件夹进行制种,其大小和infohash对比如下:

image-20200914211116906.png

1
2
3
4
5
6
7
8
9
v1.torrent
v1 infohash 1364034113f8fb0aec628400d4b1c83b83da7dd7

v2-only.torrent
v2 infohash 284bc6feb918f054c1bec2b1a26f18232728290c3580681b66a713d8e07366ef

v2-compatibility.torrent
v1 infohash 58485c1b6dc09d84da68d37272c80d7254bcb47e
v2 infohash 1560874ef639960aafaea8cf042213dc65bf3d6f32f55bdf4dec8eec865fe058

很出乎我个人的主观感觉,在目录嵌套不深或者没有较长目录的情况下,使用v2体积做出来的种子体积与v1版种子相比并没有明显优势,反而因为对每个文件都构建了pieces root,以及额外的piece layers项,导致其体积远比v1大。

即使增大区块大小(4 MiB),由于文件夹深度及层级并没有变化,而且种子文件数量并没有达到引起质变的程度。其对比同样不太明显:

image-20200920184845693.png

1
2
3
4
5
multi-v1-4194304.torrent
v1 infohash 309478779a1cae156a2f0e1e0b29693faded3a58

multi-v2-only-4194304.torrent
v2 infohash 27bc289bf9e71c04197eae5b0e07516c4d1857c64ea6757f905cd36f95dbc7b5

三、目前阶段Tracker支持方法

  1. 对info_hash取值:

    由于v2的种子在汇报时,其&info_hash= 请求字段仍然为20bytes,而在BEP52规范以及libtorrent的实现中,对于汇报的info_hash取值方法是: 种子为v2的优先裁剪sha256到前20 bytes,如果还是v1的种子,则按照原来的方法实现,其代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    // from https://github.com/arvidn/libtorrent/blob/867cf863f21747f2df7290df81a8d6a57a4d0992/include/libtorrent/info_hash.hpp#L105-L110

    // returns the v2 (truncated) info-hash, if there is one, otherwise
    // returns the v1 info-hash
    sha1_hash get_best() const
    {
    return has_v2() ? get(protocol_version::V2) : v1;
    }

    所以Tracker在接受到一个种子(被上传)的时候应该首先判断一个种子是否是v2的种子,如果是,则计算sha256($->info),并存储前20bytes到数据库。如果不是,则计算 sha1($->info)

  2. 对种子合法性的检查以及种子总大小、文件列表的获取

    在Rhilip/NexusPHP的实现中,我们对v1种子的合法性检查步骤如下 Rhilip/NexusPHP/takeupload.php#L138-L184 ,这在v1中或许足够。但我们还需要对其进行更多的检验,分别为:

    • (强制)meta version项存在且为2。(作为我们判断种子类型的关键依据)

    • (强制)piece layers项、files tree必须存在。

    • (可选)文件length值大于pieces length值对应的文件,其pieces root值在piece layers中存在。

    • (不必要)Bencode的相关格式(特别是字典序),因为目前Bencode库基本都具有对其进行排序等功能,而用户上传再下载的过程,必然同时涉及到编解码,所以即使用户上传的种子并没有完全按照字典序,在Tracker计算info_hash以及重新下载时也会重新排序。而其他的,如 i004e等问题应该在Bencode库中进行解决。

    除了对种子合法性检验之外,我们还需要对种子总文件体积以及文件列表进行获取。最终形成的伪代码(未经过验证)如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    $dict = Bencode::load($torrent_file_path);
    $info = checkTorrentDict($dict, 'info');
    $plen = checkTorrentDict($info, 'piece length', 'integer'); // Only Check without use
    $dname = checkTorrentDict($info, 'name', 'string');

    $filelist = array();

    // 对于种子是单文件还是多文件的仍然沿用原来的方法
    $totallen = $info['length'];
    if (isset($totallen)) {
    $filelist[] = array($dname, $totallen);
    $type = "single";
    } else {
    $type = "multi";
    }

    $torrent_v2 = false;
    if ($info['meta version'] === 2) {
    $torrent_v2 = true;
    // !!! IMPORTANT !!! 以下对于v2种子的检查仅代表本人思路,可能出现SyntaxError或者其他任何可能的意外或者错误。
    $ftree = checkTorrentDict($info, 'file tree', 'array');
    $piece_layers = checkTorrentDict($dict, 'piece layers');

    function loop_check_ftree($d, &$totallen = 0, &$path = []) {
    if (isset($d[''])) { // 到子叶了
    $fn = $d['']; // 获取子叶元素
    $ll = checkTorrentDict($fn, 'length', 'integer');
    $pieces_root = checkTorrentDict($fn, 'pieces root', 'string');
    if (strlen($pieces) != 32) {
    bark($lang_takeupload['std_invalid_pieces']);
    }

    // 检查过长文件的pieces root信息是否在 piece layers 中存在
    if ($ll > $plen) {
    if (!array_key_exist($pieces_root, $piece_layers)) {
    bark('long pieces not exist in piece layers');
    }
    }

    $totallen += $ll;
    $filelist[] = array(implode("/", $path), $ll); // FIXME 这样是不合适的,但是鉴于v1是这么处理的,这里沿用。事实上v2的种子本身就是树结构的。
    } else {
    $parent_path = $path; // 暂存一下当前的路径,方便后续恢复
    foreach($d as $k => $v) { // 遍历子叶
    array_push($path, $k); // 将当前项名称存入路径
    loop_check_ftree($v, $totallen, $path); // 回调检查
    $path = $parent_path; // 恢复路径
    }
    }
    }

    loop_check_ftree($ftree, $totallen, [$dname]); // 从根开始检查
    } else {
    // 按照v1的方法检查并构建相关信息
    }

    $filetree = filelistTofiletree($filelist);

    // 计算infohash
    if ($torrent_v2) {
    $raw_infohash = hash('sha256', Bencode::encode($dict['info']));
    $infohash = substr($raw_infohash, 0, 20)
    } else {
    $infohash = sha1(Bencode::encode($dict['info']));
    }

四、其他/总结

  1. Tracker并没有能力主动将种子从v1升级到v2版本,而同时维护支持v1和v2的方法又额外加重了Tracker的负担。此外,Bittorrent v2将区块hash放在info外,在info内仅保留文件根hash的操作(用来节约metadata),并不适合使用种子文件分化的PT站点。

  2. 客户端制作向前兼容(backwards compatible)的种子得不偿失(因为对同一个区块要同时进行sha1和sha256),而只制作v2支持的种子在目前(2020年9月)没有Tracker或者btclient能识别。

  3. 不可否认,随着libtorrent v2的正式释出,基于libtorrent的Deluge和qBittorrent将会可见地对bittorrent v2 进行支持。 (e.g. qBittorrent https://github.com/qbittorrent/qBittorrent/pulls?q=libtorrent+2.0 ) (但这可能并不能改变国内PT站点uTorrent横行的现状,此外Transmission 很早之前就有仍提出对bittorrent v2的支持,但目前最新的Tr 3并没有相关反映)

  4. 对于同一个文件,不管做种区块如何选择,其pieces root始终相同,对于v2的种子,做种区块只能影响piece layers的情况。这或许能为自动化辅种软件提供新的思路。

  5. 对于站点维护者来说,可以等等此feature相关实现,再考虑是否并入站点代码中。

IYUU GUI项目实现过程及下一步计划

为什么会有写IYUU GUI的想法

6月末,有人提出来能否将PTPP与IYUUAutoReseed相结合(ronggang/PT-Plugin-Plus#552),但当初我的意见偏于不结合,原因在该issue中页较为明确的提及。那时候一方面顾及大卫本人(@ledccn, IYUUAutoReseed的开发者)自己有开发web页面的想法,另一方面,是因为PTPP和IYUU确实在结合上存在一些问题(PTPP所倡导的权限管理与IYUU不相匹配、站点名映射关系、PTPP在下载器应用上也仅限于种子推送)。

image-20200722204518516.png

但相关想法一直存着。也正好最近稍微空下来写。7月9号的时候,我和大卫提起来要帮他写一个IYUU GUI出来。就这样项目启动了。

初步验证阶段

首先是用啥语言写,我最早的想法是用Python+PyQt5,原因基本上是因为我前期拿Python+thinker写过GUI程序,稍微有点经验。但简单尝试过PyQt5之后,我有点无奈的放弃了,因为布局构造太!麻!烦!了!更不要说后面一堆emit操作。

正当我想要放弃的时候,我突然想起了electron——使用 JavaScript,HTML 和 CSS 同样可以构建跨平台的桌面应用程序。也正好本人有过使用Vue写过几个小项目的经历以及参与PTPP项目的经历,使用electron来编写桌面应用程序似乎是一条可行之路。

这里再扯句B/S构架和C/S构架相关的想法。

项目要请求的API包括iyuu本身、各个下载器以及部分站点种子详情页面来构造下载链接,如果我们纯在浏览器上完成,那么一定会遇到CORS问题,这个是不可以避免的。那如果我们中间加一个服务器来合并中转请求那?可以是可以,但是这个与本项目最初的想法不一样,PHP版的IYUUAutoReseed在配置上已经过于麻烦,如果还需要用户有docker或者linux的基础搭建一个服务器来合并中转请求,是不是又加大了相关难度?

image-20200722214622082.png

脚手架选择

将Vue和electron结合的脚手架主要有以下两个: SimulatedGREG/electron-vue 以及 nklayman/vue-cli-plugin-electron-builder。两者各有不同,我最初尝试了 SimulatedGREG/electron-vue(尝试结果可见 Rhilip/IYUU-GUI@old-test),但最终选择了vue-cli-plugin-electron-builder 作为项目脚手架进行项目编写。其主要原因如下:

  1. electron-vue 内置的electron版本过低(为2.0.4),虽然可以通过强行升级的形式升级到更高版本(比如8.x或者9.x),但毕竟不是官方的支持,且部分底层依赖修改起来较为麻烦。
  2. electron-vue 没有原生对typescript的支持,事实证明了,通过使用typescript的interface抽象,使得在下载器标准化上写起来更为顺手,即使不同类型下载器内部的实现不同,但是外部暴露的方式和调用的方法能够统一起来,也便于后续继续扩展。
  3. electron-vue 已经停止开发维护了。

更详细的对比如下:

  • 项目构建

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # electron-vue 基于vue init模板建立项目
    yarn global add @vue/cli @vue/cli-init
    vue init simulatedgreg/electron-vue my-project
    cd my-project

    # vue-cli-plugin-electron-builder 直接使用vue add作为项目插件
    yarn global add @vue/cli
    vue create my-project
    cd my-project
    # vue add @vue/typescript # 增加typescript支持
    vue add electron-builder

    从项目构建的角度上看,electron-vue 使用vue init命令作为模板构建,需要额外的@vue/cli-init库支持,此外也限制了整个项目必须基于该模板。而vue-cli-plugin-electron-builder较为方便,使用的是vue add命令,作为vue-cli的插件构建,在项目构建上更为灵活。

  • 项目结构

    从项目结构上来说,electron-vue在src目录下,存在main和renderer两个目录,分别控制main process和renderer process,暴露出更多信息方便对两个进程进行控制。而vue-cli-plugin-electron-builder在src下,除background.(ts|js)外,其他文件均为renderer process使用,相对暴露更少的参数信息,更多的则通过对根目录下的vue.config.js进行配置。

  • 代码调试 debug

    可能是因为代码结构不一样,在实际开发过程中,即使是对view的更改,也会导致基于electron-vue项目的进程重启,导致devtools面板丢失信息。而vue-cli-plugin-electron-builder在这方面处理的较为良好,除非是对于background.(ts|js)以及store等底层模块进行修改,一般情况下,对view的修改不会导致main process重启,对于debug来说较为方便。

在经过一番测试后,我抛弃了一开始使用的electron-vue脚手架,使用vue-cli-plugin-electron-builder脚手架来构建这个IYUU GUI项目。且因为electron 9.x在使用xhr时候存在不可避免的CORS问题(electron/electron#23664),最终将electron的版本确定在8.x上。

下载器构建

与较为冗杂,且在下载器上只考虑种子推送的PTPP相比,IYUU还要求实现从下载器中获取种子信息的功能,特别是种子的info_hash信息,以及保存位置。所以考虑使用typescript的interface进行接口统一,在继承接口的前提下,完成各具体下载器的思想。其接口如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export interface TorrentClient {
config: TorrentClientConfig;

ping: () => Promise<boolean>;

getAllTorrents: () => Promise<Torrent[]>
getTorrentsBy: (filter: TorrentFilterRules) => Promise<Torrent[]>
getTorrent: (id: any) => Promise<Torrent>;

addTorrent: (url: string, options?: Partial<AddTorrentOptions>) => Promise<boolean>;
pauseTorrent: (id: any) => Promise<boolean>;
resumeTorrent: (id: any) => Promise<boolean>;
removeTorrent: (id: any, removeData?: boolean) => Promise<boolean>;
}

在下载器的具体实现上,参考了原来PTPP时候写的一些逻辑以及 @scttcper/qbittorrent 等系列实现。但有些稍许还是可以再做讲述,例如:

  • 因为electron的renderer process同样实现了部分node模块,所以我们可以使用Buffer.from(req.data, 'binary').toString('base64')的方法,快速将通过axios请求得到的文件(binaryarray)转换成base64形式发送给下载器(Transmission和Deluge)

  • 通过工厂函数,依据字典中的type属性,生成对应的下载器类型。同样的方法,在生成站点下载链接的时候也一并使用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    export default function (config: TorrentClientConfig): TorrentClient {
    switch (config.type) {
    case "qbittorrent":
    return new Qbittorrent(config)
    case "transmission":
    return new Transmission(config)
    case "deluge":
    return new Deluge(config)
    }
    }
  • 而下载链接方式生成,则采用同样的方法,根据站点信息,调用不同的构造函数,值得注意的是,因为对于下载链接的构造,特殊站点需要进行网络请求,所以此工厂函数,使用async/await修饰符的形式,将异步方法转为同步形式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    export default async function (reseedInfo: TorrentInfo, site: EnableSite) {
    switch (site.site) {
    case 'hdchina':
    return await HdChinaDownload(reseedInfo, site)
    case 'hdcity':
    return await HDCityDownload(reseedInfo, site)
    case 'hdsky':
    return await HDSkyDownload(reseedInfo, site)
    default:
    return await defaultSiteDownload(reseedInfo, site)
    }
    }

下一步打算

本人对IYUU GUI项目的定位为:如果没使用过IYUU的人,想要尝试或者想要了解,又苦于没有相关基础,直接上手官方PHP版较为困难,使用本项目可以对整个IYUU的实现及功能进行了解,能为后续进阶使用提供机会。

所以目前来看,整个项目的基本骨架已经完成。就目前来看,一些远期的计划有:

  • 对于IYUU GUI的文档(Wiki)做相应完善
  • 对PHP版的转种任务进行支持,这一块在原来内测期间有过打算,但因为时间较为紧张,且内测快结束时,本人现实中突然有新的事情发生,最终不了了之。
  • 对其他类型客户端进行支持。本人在IYUU GUI中,除了实现Qbittorrent以及Transmission的客户端外,还实现了Deluge客户端。目前远期来看,还有支持rtorrent的打算,但是之前在内测期间,发现其过于麻烦(直接的种子推送还简单,比较麻烦的时HTTPRPC部分),所以一时也没来得及支持。
  • 定时任务:这块之前在内测群里面,有人建议过,但是我觉得有些繁琐且没有必要,后续再做思考吧。

tkinter和pyinstaller初尝 : 国自然结题报告下载工具 视窗化改造

我在今年2月的时候写了个 Rhilip/NSFC_conclusion_downloader 来辅助我从科学基金共享服务网(科技成果信息系统) 下载 国自然结题报告,并生成PDF文件。截至目前也有了12个star,并且在知乎上介绍之后,也开始有其他使用的人。

可毕竟原项目需要一定的python基础(基础到极限了),但使用人(包括我们课题组的同学)多数并不具备编程基础,导致原脚本形式的repo难以被使用。

image-20200709175406546.png

这段时间真好稍空,翻看“知乎”的时候正好看见别人的抱怨。便想着将其写一个GUI出来,方便其他人的使用。

最终形成的软件截图如下:

gui_usage.png

你可以在 Release页面 直接下载,然后解压后直接可以打开使用。

image-20200709213546323.png

!!!后面的,对tkinter和pyinstaller等具体编写过程不感兴趣的可以不用看了!!!

GUI框架编写调查

因为之前写各类脚本的时候并没有考虑过可视化(虽然html写过很多),所以这次对python的gui编程也进行了一定的资料收集。

首先是GUI框架的选取,Python中有名的GUI框架就tkinter和PyQT5。因为本项目是一个小项目,而且也可以算是本人GUI编写入门。经过简单到不能再简单的抉择,选择tkinter作为GUI框架,一方面是因为这个库入手较为简单,很适合作为Python的GUI编写入门,另一方面是因为tkinter是Python的内置库,不需要像PtQt5一样,安装Qt5环境,这对于后续pyinstaller打包较为方便(可以有效的减少打包后的文件体积)。

而从tkinter的具体写法上,也有两种,一种流程式,一种对象式,对比如下:

1
2
3
4
5
6
7
8
9
10
11
# 流程式
import tkinter as tk

window = tk.Tk()
window.title('my window')

##窗口尺寸
window.geometry('200x200')

##显示出来
windo.mainloop()

以及

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 对象式
import tkinter as tk

class MainApplication(tk.Frame):
def __init__(self, parent, *args, **kwargs):
tk.Frame.__init__(self, parent, *args, **kwargs)
self.parent = parent

<create the rest of your GUI here>

if __name__ == "__main__":
root = tk.Tk()
MainApplication(root).pack(side="top", fill="both", expand=True)
root.mainloop()

一些讨论可以见 python - Best way to structure a tkinter application? - Stack Overflow,本处同样鉴于初步尝试,使用“流程式”来进行程序编写。

GUI编写过程

在上述思想的决定下,我写出了 一个gui版本 feat: 写一个GUI出来 · Rhilip/NSFC_conclusion_downloader@f14a2c9

在这个版本,实现了现在的各个控件的基本布局定位,使用grid布局,分成上中下三栏。并通过改写原来的nsfc_downloader.py 文件,使得其能适应GUI环境,而不对原CLI调用产生较大的影响。

但这样的程序在随后的调试中发现了更多问题:

  1. 国自然官网最近经常性报错Internal Server Error。而原来直接返回错误代码500对于小白来说莫名其妙,不如直接返回错误信息好些。

    image-20200709223456540.png

  2. 原来点击下载按钮后,因为下载操作涉及大量网络请求,并且内部实现使用id递增,遇到404退出的实现,比较难使用队列+线程形式进行优化。这样就导致下载操作卡住GUI主线程,甚至导致程序被windows系统认为无响应。使用线程的形式进行优化,并再次修改nsfc_downloader的实现,将更多参数抛到对象内部,而不是过程中,主线程使用每1s轮询的形式获取运行时候的参数信息,这样就顺带实现了下载进度的显示。

    image-20200709223814569.png

  3. 但是这样改,又引出了一个问题,就是“点击下载”按钮可能被多次点击,所以需要在点击按钮后禁用按钮,并在一个下载任务完成后还原按钮状况,这个和前端防重放一样。

    1
    2
    3
    4
    5
    6
    7
    # 禁用按钮
    input_button.config(state='disabled')

    <...下载任务..>

    # 下载任务完成后,恢复按钮状态
    input_button.config(state='normal')

Pyinstaller打包发布

而pyinstaller同样在最开始遇到了一些小问题,如果直接使用 pyinstaller gui.py 则出不来exe可执行文件,而加上 -F参数后,虽然生成了exe文件,但是是先出现命令行窗口,之后才有GUI出来。

后来重新查阅pyinstaller的文档,终于知道要增加-w参数来实现只显示GUI窗口。最终确定使用以下命令进行打包发布

1
2
(venv) NSFC_conclusion_downloader>pip install pyinstaller
(venv) NSFC_conclusion_downloader>pyinstaller gui.py -n nsfc_downloader -Fw

生成的程序大小在11M左右,但本项目的核心代码就两个nsfc_downloader.py, gui.py ,整体实现不到10KB。这也因为pyinstaller会打包python解释器以及使用了的库的原因。而即使是个空项目(只有hello world),使用pyinstaller打包出来的体积也在10M以上。

就这样,很圆满的完成了整个GUI的编写,如果基金委那边对于目前输出的结构不做更改的话,之后应该不会再来更新这个repo了。

基于qbittorrent完成回调和Pt-Gen简介生成的美剧发种机

有些人可能对我有些了解,我在17年时候完成了本人的第一个 Rhilip/Pt-Autoseed 并之后持续运行到了2020年年初(自己的关掉了,仅剩下Ourbits还在使用该项目)。

受限制于当时才开始学习编程相关的背景,Pt-Autoseed被设计成一个需要持续性运行且需要Mysql数据库(虽然仅一张很普通的表)做进一步支撑的项目。后续 rachpt/AutoSeed 的出现,让我认识到其实bt软件的完成后回调也能做很多有意思的事情,但受限制于代码整体逻辑已经完成,且本人缺少维护时间,故对其重构只能不了了之。

2020年初的时候,慢摇大佬找到我,想让我给SJTU写一个0day美剧的发种机(不对,是19年年末的时候,只不过被我拖到了20年年初),鉴于我当时想把Pt-Autoseed给archive了的想法,故觉得“不如重新写一个简易些的”。就又有了这个可以水一篇Blog的项目: Rhilip/SJTU-Autoseed