pixiv.d 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  1. /*
  2. * pixiv_down - CLI-based downloading tool for https://www.pixiv.net.
  3. * Copyright (C) 2024 Mio
  4. *
  5. * This program is free software: you can redistribute it and/or modify
  6. * it under the terms of the GNU General Public License as published by
  7. * the Free Software Foundation, version 3 of the License.
  8. *
  9. * This program is distributed in the hope that it will be useful,
  10. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. * GNU General Public License for more details.
  13. *
  14. * You should have received a copy of the GNU General Public License
  15. * along with this program. If not, see <https://www.gnu.org/licenses/>.
  16. */
  17. module pd.pixiv;
  18. import std.array : appender;
  19. import std.format : format;
  20. import std.json : JSONException, JSONValue, parseJSON;
  21. import std.experimental.logger;
  22. import pd.configuration;
  23. import pd.utils;
  24. static if (__VERSION__ <= 2081L) {
  25. import std.json : JSON_TYPE;
  26. /*
  27. While we could just alias JSONType = JSON_TYPE and use the ALL CAPS
  28. version of the enum members in the code, they are technically deprecated.
  29. */
  30. enum JSONType : byte
  31. {
  32. null_ = JSON_TYPE.NULL,
  33. string = JSON_TYPE.STRING,
  34. integer = JSON_TYPE.INTEGER,
  35. uinteger = JSON_TYPE.UINTEGER,
  36. float_ = JSON_TYPE.FLOAT,
  37. array = JSON_TYPE.ARRAY,
  38. object = JSON_TYPE.OBJECT,
  39. true_ = JSON_TYPE.TRUE,
  40. false_ = JSON_TYPE.FALSE
  41. }
  42. } else {
  43. import std.json : JSONType;
  44. }
  45. static if (__VERSION__ <= 2082L) {
  46. bool boolean(const ref JSONValue self) pure @safe
  47. {
  48. if (self.type == JSONType.true_) return true;
  49. if (self.type == JSONType.false_) return false;
  50. throw new JSONException("JSONValue is not a boolean type");
  51. }
  52. }
  53. immutable struct ArtworkInfo
  54. {
  55. string id;
  56. string title;
  57. string type;
  58. string userId;
  59. string userName;
  60. long numberOfPages;
  61. string originalURL;
  62. string createDate;
  63. bool isR18;
  64. }
  65. immutable struct ArtworkPage
  66. {
  67. string originalURL;
  68. }
  69. // https://www.pixiv.net/ajax/user/:id/:type/bookmarks
  70. immutable struct Bookmarks
  71. {
  72. Bookmark[] works;
  73. long total;
  74. }
  75. immutable struct Bookmark
  76. {
  77. string id;
  78. bool isMasked;
  79. string maskReason;
  80. BookmarkData bookmarkData;
  81. }
  82. immutable struct BookmarkData
  83. {
  84. string id;
  85. // 'private' in JSON
  86. bool isPrivate;
  87. }
  88. // https://www.pixiv.net/ajax/novel/$id
  89. immutable struct NovelInfo
  90. {
  91. string id;
  92. string title;
  93. string userId;
  94. string userName;
  95. string content;
  96. string description;
  97. string createDate;
  98. bool isR18;
  99. }
  100. immutable struct UgoiraFrame
  101. {
  102. string filename;
  103. long delay;
  104. }
  105. immutable struct UgoiraInfo
  106. {
  107. UgoiraFrame[] frames;
  108. string originalSource;
  109. string mimeType;
  110. }
  111. immutable struct User
  112. {
  113. string id;
  114. string userName;
  115. }
  116. immutable struct UserProfile
  117. {
  118. string[] illusts;
  119. string[] manga;
  120. string[] novels;
  121. }
  122. class PixivException : Exception
  123. {
  124. this(string msg)
  125. {
  126. super(msg);
  127. }
  128. }
  129. class PixivJSONException : PixivException
  130. {
  131. this(string msg)
  132. {
  133. super(msg);
  134. }
  135. }
  136. ArtworkInfo fetchArtworkInfo(string id, in Config config)
  137. {
  138. auto json = makeJSONRequest(format("/illust/%s", id), config);
  139. try {
  140. auto jsonBody = json["body"];
  141. string type;
  142. switch (jsonBody["illustType"].integer)
  143. {
  144. case 0:
  145. type = "illustration";
  146. break;
  147. case 1:
  148. type = "manga";
  149. break;
  150. case 2:
  151. type = "ugoira";
  152. break;
  153. case 3:
  154. type = "novel";
  155. break;
  156. default:
  157. type = "unknown";
  158. errorf("Unknown artwork type: %d", jsonBody["illustType"].integer);
  159. break;
  160. }
  161. return ArtworkInfo(
  162. jsonBody["id"].str,
  163. jsonBody["title"].str,
  164. type,
  165. jsonBody["userId"].str,
  166. jsonBody["userName"].str,
  167. jsonBody["pageCount"].integer,
  168. jsonBody["urls"]["original"].str,
  169. jsonBody["createDate"].str,
  170. jsonBody["xRestrict"].integer == 1
  171. );
  172. } catch (JSONException e) {
  173. errorf("parsing JSON: %s", e.msg);
  174. if ("message" in json && json["message"].str != "") {
  175. throw new PixivJSONException(json["message"].str);
  176. }
  177. throw new PixivJSONException(e.msg);
  178. }
  179. }
  180. ArtworkPage[] fetchArtworkPages(string id, in Config config)
  181. {
  182. auto response = appender!string;
  183. ArtworkPage[] pages;
  184. auto client = makeHTTPClient(config.sessionid);
  185. client.url = format("https://www.pixiv.net/ajax/illust/%s/pages", id);
  186. client.onReceive = (ubyte[] data) {
  187. response.put(data);
  188. return data.length;
  189. };
  190. client.perform();
  191. auto json = parseJSON(response[]);
  192. try {
  193. auto jsonBody = json["body"];
  194. pages.reserve(jsonBody.array.length);
  195. foreach(ref page; jsonBody.array) {
  196. pages ~= ArtworkPage(page["urls"]["original"].str);
  197. }
  198. } catch (JSONException e) {
  199. if ("message" in json) {
  200. auto msg = json["message"].str;
  201. if (msg.length != 0) {
  202. throw new PixivJSONException(msg);
  203. }
  204. }
  205. throw new PixivJSONException(e.msg);
  206. }
  207. return pages;
  208. }
  209. import std.regex : ctRegex;
  210. private enum TOKEN_Regex = ctRegex!(`token":"([^"]+)"`);
  211. /**
  212. * Fetch the Cross-Site Request Forgery token that will be used to
  213. * un-bookmark works.
  214. *
  215. * Params:
  216. * sessionID = The `PHPSESSID` cookie
  217. *
  218. * Returns: A string containing the CSRF token.
  219. */
  220. string fetchCSRFToken(in string sessionID)
  221. {
  222. import std.net.curl: HTTP;
  223. import std.regex : matchFirst;
  224. import std.string : split;
  225. const userID = split(sessionID, "_")[0];
  226. auto html = appender!string;
  227. // Make our own instead of using util.makeHTTPClient so that we can
  228. // better control our headers.
  229. auto client = HTTP();
  230. client.addRequestHeader("Accept", "text/html,application/xhtml+xml");
  231. client.addRequestHeader("Host", "www.pixiv.net");
  232. client.addRequestHeader("Referer", "https://www.pixiv.net/users/"~userID);
  233. client.setCookie("PHPSESSID=" ~ sessionID);
  234. client.setUserAgent(UserAgent);
  235. client.onReceive = (ubyte[] data) {
  236. html ~= data;
  237. return data.length;
  238. };
  239. client.url = "https://www.pixiv.net/users/"~userID~"/following";
  240. client.perform();
  241. auto tokenCapture = matchFirst(html[], TOKEN_Regex);
  242. if (tokenCapture.empty || tokenCapture.length < 1) {
  243. throw new Exception("Could not retrieve CSRF Token");
  244. }
  245. trace("successfully retrieved CSRF Token");
  246. return tokenCapture[1];
  247. }
  248. // https://www.pixiv.net/ajax/novel/$id
  249. NovelInfo fetchNovelInfo(string id, in Config config)
  250. {
  251. auto json = makeJSONRequest(format("/novel/%s", id), config);
  252. try {
  253. auto jsonBody = json["body"];
  254. return NovelInfo(
  255. jsonBody["id"].str,
  256. jsonBody["title"].str,
  257. jsonBody["userId"].str,
  258. jsonBody["userName"].str,
  259. jsonBody["content"].str,
  260. jsonBody["description"].str,
  261. jsonBody["createDate"].str,
  262. jsonBody["xRestrict"].integer == 1
  263. );
  264. } catch (JSONException e) {
  265. if ("message" in json && json["message"].str != "") {
  266. throw new PixivJSONException(json["message"].str);
  267. }
  268. throw new PixivJSONException(e.msg);
  269. }
  270. }
  271. // https://www.pixiv.net/ajax/follow_latest/novel?p=1&mode=all
  272. string[] fetchNovelLatest(int page, in Config config)
  273. {
  274. import std.conv : to;
  275. string[string] params = [
  276. "p": to!string(page),
  277. "mode": "all"
  278. ];
  279. auto json = makeJSONRequest("/follow_latest/novel", config, params);
  280. try {
  281. string[] ret;
  282. auto jsonBody = json["body"];
  283. if (jsonBody["thumbnails"]["novel"].type != JSONType.array) {
  284. warning("Novel thumbnails was not an array");
  285. return [];
  286. }
  287. ret.reserve(jsonBody["thumbnails"]["novel"].array.length);
  288. foreach(novel; jsonBody["thumbnails"]["novel"].array) {
  289. ret ~= novel["id"].str;
  290. }
  291. return ret;
  292. } catch (JSONException e) {
  293. errorf("parsing JSON: %s", e.msg);
  294. if ("message" in json && json["message"].str != "") {
  295. throw new PixivJSONException(json["message"].str);
  296. }
  297. throw new PixivJSONException(e.msg);
  298. }
  299. }
  300. string[] fetchFollowLatest(int page, in Config config)
  301. {
  302. import std.conv : to;
  303. string[string] params = [
  304. "p": to!string(page),
  305. "mode": "all"
  306. ];
  307. auto json = makeJSONRequest("/follow_latest/illust", config, params);
  308. try {
  309. string[] ids;
  310. auto jsonBody = json["body"];
  311. ids.reserve(jsonBody["thumbnails"]["illust"].array.length);
  312. // TODO(mio): Novels exist in body.thumbnails.novel
  313. foreach(thumbnail; jsonBody["thumbnails"]["illust"].array) {
  314. ids ~= thumbnail["id"].str;
  315. }
  316. return ids;
  317. } catch (JSONException e) {
  318. if ("message" in json && json["message"].str != "") {
  319. throw new PixivJSONException(json["message"].str);
  320. }
  321. throw new PixivJSONException(e.msg);
  322. }
  323. }
  324. User[] fetchFollowing(bool private_, long skip, out long total, in Config config)
  325. {
  326. import std.conv : to;
  327. import std.string : split;
  328. string sSkip = to!string(skip);
  329. const id = split(config.sessionid, '_')[0];
  330. string[string] params = [
  331. "offset": sSkip,
  332. "rest": private_ ? "hide" : "show",
  333. "limit": "24"
  334. ];
  335. auto json = makeJSONRequest(format("/user/%s/following", id), config, params);
  336. User[] users;
  337. try {
  338. auto jsonBody = json["body"];
  339. total = jsonBody["total"].integer;
  340. users.reserve(total);
  341. foreach(user; jsonBody["users"].array) {
  342. users ~= User(user["userId"].str, user["userName"].str);
  343. }
  344. return users;
  345. } catch (JSONException e) {
  346. if ("message" in json && json["message"].str != "") {
  347. throw new PixivJSONException(json["message"].str);
  348. }
  349. throw new PixivJSONException(e.msg);
  350. }
  351. }
  352. UgoiraInfo fetchUgoiraInfo(ArtworkInfo info, in Config config)
  353. {
  354. auto json = makeJSONRequest(format("/illust/%s/ugoira_meta", info.id),
  355. config);
  356. try {
  357. UgoiraFrame[] frames;
  358. auto jsonBody = json["body"];
  359. foreach(frame; jsonBody["frames"].array) {
  360. frames ~= UgoiraFrame(frame["file"].str, frame["delay"].integer);
  361. }
  362. return UgoiraInfo(
  363. frames,
  364. jsonBody["originalSrc"].str,
  365. jsonBody["mime_type"].str
  366. );
  367. } catch (JSONException e) {
  368. errorf("parsing JSON: %s", e.msg);
  369. if ("message" in json && json["message"].str.length != 0) {
  370. errorf("pixiv error: %s", json["message"].str);
  371. throw new PixivJSONException(json["message"].str);
  372. }
  373. throw new PixivJSONException(e.msg);
  374. }
  375. }
  376. Bookmarks fetchUserBookmarks(string contentType, bool isPrivate, long offset, in Config config)
  377. {
  378. import std.conv : to;
  379. import std.string : split;
  380. string[string] params = [
  381. "rest": isPrivate ? "hide" : "show",
  382. "offset": to!string(offset),
  383. "limit": "48",
  384. "tag": ""
  385. ];
  386. const userId = config.sessionid.split("_")[0];
  387. infof("current user ID: %s", userId);
  388. auto path = format("/user/%s/%s/bookmarks", userId, contentType);
  389. auto json = makeJSONRequest(path, config, params);
  390. try {
  391. Bookmark[] works;
  392. auto jsonBody = json["body"];
  393. foreach(bookmark; jsonBody["works"].array) {
  394. if (bookmark["isMasked"].boolean) {
  395. works ~= Bookmark(
  396. bookmark["id"].integer.to!string,
  397. bookmark["isMasked"].boolean,
  398. bookmark["maskReason"].str,
  399. BookmarkData(
  400. bookmark["bookmarkData"]["id"].str,
  401. bookmark["bookmarkData"]["private"].boolean
  402. )
  403. );
  404. } else {
  405. works ~= Bookmark(
  406. bookmark["id"].str,
  407. bookmark["isMasked"].boolean,
  408. "",
  409. BookmarkData(
  410. bookmark["bookmarkData"]["id"].str,
  411. bookmark["bookmarkData"]["private"].boolean
  412. )
  413. );
  414. }
  415. }
  416. return Bookmarks(works, jsonBody["total"].integer);
  417. } catch (JSONException e) {
  418. if ("message" in json && json["message"].str != "") {
  419. throw new PixivJSONException(json["message"].str);
  420. }
  421. throw new PixivJSONException(e.msg);
  422. }
  423. }
  424. UserProfile fetchUserProfie(string id, in Config config)
  425. {
  426. auto path = format("/user/%s/profile/all", id);
  427. auto json = makeJSONRequest(path, config);
  428. try {
  429. import std.exception : assumeUnique;
  430. auto jsonBody = json["body"];
  431. // When an artist doesn't have any works of a certain type, then the
  432. // returned JSON type is an array. When they DO, it's an object.
  433. immutable illusts = (jsonBody["illusts"].type == JSONType.object) ?
  434. assumeUnique(jsonBody["illusts"].object.keys) :
  435. [];
  436. immutable manga = jsonBody["manga"].type == JSONType.object ?
  437. assumeUnique(jsonBody["manga"].object.keys) :
  438. [];
  439. immutable novels = (jsonBody["novels"].type == JSONType.object) ?
  440. assumeUnique(jsonBody["novels"].object.keys) :
  441. [];
  442. return UserProfile(illusts, manga, novels);
  443. } catch (JSONException e) {
  444. if ("message" in json && json["message"].str != "") {
  445. throw new PixivJSONException(json["message"].str);
  446. }
  447. throw new PixivJSONException(e.msg);
  448. }
  449. }
  450. User fetchUser(string id, in Config config)
  451. {
  452. auto path = format("/user/%s", id);
  453. auto json = makeJSONRequest(path, config, ["full": "1"]);
  454. try {
  455. auto jsonBody = json["body"];
  456. return User(
  457. jsonBody["userId"].str,
  458. jsonBody["name"].str
  459. );
  460. } catch (JSONException e) {
  461. if ("message" in json && json["message"].str != "") {
  462. throw new PixivJSONException(json["message"].str);
  463. }
  464. throw new PixivJSONException(e.msg);
  465. }
  466. }
  467. // /ajax/illusts/bookmarks/delete
  468. bool postBookmarksDelete(string bookmarkID, in string csrfToken, in string type, in Config config)
  469. {
  470. import std.net.curl;
  471. import std.string : split;
  472. const uid = config.sessionid.split("_")[0];
  473. auto response = appender!string;
  474. auto client = HTTP();
  475. client.method = HTTP.Method.post;
  476. client.url = "https://www.pixiv.net/ajax/"~type~"/bookmarks/delete";
  477. client.setUserAgent(UserAgent);
  478. client.setCookie("PHPSESSID="~config.sessionid);
  479. client.addRequestHeader("Origin", "https://www.pixiv.net");
  480. client.addRequestHeader("X-Csrf-Token", csrfToken);
  481. client.addRequestHeader("Referer", type == "illusts" ?
  482. "https://www.pixiv.net/users/"~uid~"/bookmarks/artworks" :
  483. "https://www.pixiv.net/users/"~uid~"/bookmarks/novels");
  484. if (type == "novels") {
  485. client.setPostData("del=1&book_id="~bookmarkID, "application/x-www-form-urlencoded");
  486. } else {
  487. client.setPostData("bookmark_id="~bookmarkID, "application/x-www-form-urlencoded");
  488. }
  489. client.onReceive = (ubyte[] data) {
  490. response ~= data;
  491. return data.length;
  492. };
  493. client.perform();
  494. auto json = parseJSON(response.data);
  495. if (json["error"].boolean) {
  496. errorf("POST /%s/bookmarks/delete: %s", type, json["message"].str);
  497. return false;
  498. }
  499. return true;
  500. }
  501. private:
  502. JSONValue makeJSONRequest(string path, in Config config, string[string] args = string[string].init)
  503. {
  504. import mlib.search_params;
  505. scope params = new URLSearchParams();
  506. foreach(const ref key, const ref value; args) {
  507. params.append(key, value);
  508. }
  509. params.append("lang", config.locale);
  510. auto response = appender!string;
  511. auto client = makeHTTPClient(config.sessionid);
  512. client.url = format("https://www.pixiv.net/ajax%s?%s", path, params.toString());
  513. client.onReceive = (ubyte[] data) {
  514. response.put(data);
  515. return data.length;
  516. };
  517. tracef("Making request to %s?%s", path, params.toString());
  518. client.perform();
  519. return parseJSON(response[]);
  520. }