diff --git a/docs/anime.md b/docs/anime.md
index 8e897496f..8232fb643 100644
--- a/docs/anime.md
+++ b/docs/anime.md
@@ -246,6 +246,12 @@ pageClass: routes
见 [#bilibili](/social-media.html#bilibili)
+## CCC 創作集
+
+### 漫畫
+
+
+
## DLsite
### 当前日期发售的新产品
diff --git a/lib/v2/creative-comic/book.js b/lib/v2/creative-comic/book.js
new file mode 100644
index 000000000..a14773b05
--- /dev/null
+++ b/lib/v2/creative-comic/book.js
@@ -0,0 +1,60 @@
+const { parseDate } = require('@/utils/parse-date');
+const { art } = require('@/utils/render');
+const path = require('path');
+const { getUuid, getBook, getChapter, getChapters, getImgEncrypted, getImgKey, decrypt, getRealKey, siteHost } = require('./utils');
+
+module.exports = async (ctx) => {
+ const { id, coverOnly = 'true', quality = '1' } = ctx.params;
+ const uuid = await getUuid(ctx.cache.tryGet);
+ let { data: book } = await getBook(id, uuid);
+ let { data: chapters } = await getChapters(id, uuid);
+ book = book.data;
+ chapters = chapters.data;
+
+ const items = await Promise.all(
+ chapters.chapters.map(async (c) => {
+ let pages;
+ if (coverOnly !== 'true' && coverOnly !== '1') {
+ let { data: chapter } = await getChapter(c.id, uuid);
+ chapter = chapter.data;
+
+ if (chapter.chapter.free_day === null || chapter.chapter.free_day === 0) {
+ pages = await Promise.all(
+ chapter.chapter.proportion.map(async (p) => {
+ let { data: imgKey } = await getImgKey(p.id, uuid);
+ imgKey = imgKey.data.key;
+
+ const realKey = getRealKey(imgKey);
+ const encrypted = await getImgEncrypted(p.id, quality);
+
+ return ctx.cache.tryGet(`${siteHost}/fs/chapter_content/encrypt/${p.id}/${quality}`, () => decrypt(encrypted, realKey));
+ })
+ );
+ }
+ }
+
+ return {
+ title: c.vol_name,
+ description: art(path.join(__dirname, 'templates/chapter.art'), {
+ chapter: c,
+ pages,
+ cover: c.image1,
+ }),
+ pubDate: parseDate(c.online_at),
+ updated: parseDate(c.updated_at),
+ link: `https://www.creative-comic.tw/reader_comic/${c.id}`,
+ author: book.author.map((author) => author.name).join(', '),
+ category: book.tags.map((tag) => tag.name),
+ };
+ })
+ );
+
+ ctx.state.data = {
+ title: `${book.name} | CCC創作集`,
+ description: `${book.brief} ${book.description}`,
+ link: book.share_link,
+ image: book.image1,
+ item: items,
+ language: 'zh-hant',
+ };
+};
diff --git a/lib/v2/creative-comic/maintainer.js b/lib/v2/creative-comic/maintainer.js
new file mode 100644
index 000000000..3dcaecb97
--- /dev/null
+++ b/lib/v2/creative-comic/maintainer.js
@@ -0,0 +1,3 @@
+module.exports = {
+ '/book/:id/:coverOnly?/:quality?': ['TonyRL'],
+};
diff --git a/lib/v2/creative-comic/radar.js b/lib/v2/creative-comic/radar.js
new file mode 100644
index 000000000..9e7af7531
--- /dev/null
+++ b/lib/v2/creative-comic/radar.js
@@ -0,0 +1,13 @@
+module.exports = {
+ 'creative-comic.tw': {
+ _name: 'CCC 創作集',
+ '.': [
+ {
+ title: '漫畫',
+ docs: 'https://docs.rsshub.app/anime.html#ccc-chuang-zuo-ji',
+ source: ['/book/:id/*'],
+ target: '/creative-comic/:id',
+ },
+ ],
+ },
+};
diff --git a/lib/v2/creative-comic/router.js b/lib/v2/creative-comic/router.js
new file mode 100644
index 000000000..23ab64e76
--- /dev/null
+++ b/lib/v2/creative-comic/router.js
@@ -0,0 +1,3 @@
+module.exports = (router) => {
+ router.get('/book/:id/:coverOnly?/:quality?', require('./book'));
+};
diff --git a/lib/v2/creative-comic/templates/chapter.art b/lib/v2/creative-comic/templates/chapter.art
new file mode 100644
index 000000000..d65dacfbd
--- /dev/null
+++ b/lib/v2/creative-comic/templates/chapter.art
@@ -0,0 +1,11 @@
+{{ if chapter }}
+
{{ chapter.name }}
+{{ /if }}
+{{ if pages }}
+ {{ each pages page }}
+
+ {{ /each }}
+{{ /if }}
+{{ if cover }}
+
+{{ /if }}
diff --git a/lib/v2/creative-comic/utils.js b/lib/v2/creative-comic/utils.js
new file mode 100644
index 000000000..6bed527c2
--- /dev/null
+++ b/lib/v2/creative-comic/utils.js
@@ -0,0 +1,96 @@
+const got = require('@/utils/got');
+const CryptoJS = require('crypto-js');
+
+const apiHost = 'https://api.creative-comic.tw';
+const siteHost = 'https://www.creative-comic.tw';
+const device = 'web_desktop';
+const DEFAULT_TOKEN = 'freeforccc2020reading';
+
+const getBook = (bookId, uuid) =>
+ got(`${apiHost}/book/${bookId}/info`, {
+ headers: {
+ device,
+ uuid,
+ },
+ });
+
+const getChapter = (id, uuid) =>
+ got(`${apiHost}/book/chapter/${id}`, {
+ headers: {
+ device,
+ uuid,
+ },
+ });
+
+const getChapters = (bookId, uuid) =>
+ got(`${apiHost}/book/${bookId}/chapter`, {
+ headers: {
+ device,
+ uuid,
+ },
+ });
+
+const getImgEncrypted = async (pageId, quality) => {
+ const { data: res } = await got(`${siteHost}/fs/chapter_content/encrypt/${pageId}/${quality}`, {
+ headers: {
+ device,
+ },
+ responseType: 'buffer',
+ });
+ return Buffer.from(res).toString('base64');
+};
+
+const getImgKey = (pageId, uuid) =>
+ got(`${apiHost}/book/chapter/image/${pageId}`, {
+ headers: {
+ device,
+ uuid,
+ },
+ });
+
+const getUuid = (tryGet) =>
+ tryGet('creative-comic:uuid', async () => {
+ const { data } = await got(`${apiHost}/guest`, {
+ headers: {
+ device,
+ },
+ });
+ return data.data;
+ });
+
+const decrypt = (encrypted, secrets) =>
+ CryptoJS.AES.decrypt(encrypted, CryptoJS.enc.Hex.parse(secrets.key), {
+ iv: CryptoJS.enc.Hex.parse(secrets.iv),
+ mode: CryptoJS.mode.CBC,
+ padding: CryptoJS.pad.Pkcs7,
+ }).toString(CryptoJS.enc.Utf8);
+
+const token2Key = (token) => {
+ const t = CryptoJS.SHA512(token).toString();
+ return {
+ key: t.substring(0, 64),
+ iv: t.substring(30, 62), // t.substr(30, 32)
+ };
+};
+
+const getRealKey = (imgKey, token = DEFAULT_TOKEN) => {
+ const secrets = token2Key(token);
+ const key = decrypt(imgKey, secrets);
+ const realKey = key.split(':');
+ return {
+ key: realKey[0],
+ iv: realKey[1],
+ };
+};
+
+module.exports = {
+ getBook,
+ getChapter,
+ getChapters,
+ getImgEncrypted,
+ getImgKey,
+ getUuid,
+ decrypt,
+ token2Key,
+ getRealKey,
+};