Files
venera-configs/shonenjumpplus.js

468 lines
13 KiB
JavaScript

class ShonenJumpPlus extends ComicSource {
name = "少年ジャンプ+";
key = "shonen_jump_plus";
version = "1.0.1";
minAppVersion = "1.2.1";
url =
"https://git.nyne.dev/nyne/venera-configs/raw/branch/main/shonen_jump_plus.js";
deviceId = this.generateDeviceId();
bearerToken = null;
userAccountId = null;
tokenExpiry = 0;
get headers() {
return {
"Origin": "https://shonenjumpplus.com",
"Referer": "https://shonenjumpplus.com/",
"X-Giga-Device-Id": this.deviceId,
"User-Agent": "ShonenJumpPlus-Android/4.0.19",
};
}
apiBase = `https://shonenjumpplus.com/api/v1`;
generateDeviceId() {
let result = "";
const chars = "0123456789abcdef";
for (let i = 0; i < 16; i++) {
result += chars[randomInt(0, chars.length - 1)];
}
return result;
}
init() { }
explore = [
{
title: "少年ジャンプ+",
type: "singlePageWithMultiPart",
load: async () => {
await this.ensureAuth();
const response = await this.graphqlRequest("HomeCacheable", {});
if (!response || !response.data || !response.data.homeSections) {
throw "Cannot fetch home sections";
}
const sections = response.data.homeSections;
const dailyRankingSection = sections.find((section) =>
section.__typename === "DailyRankingSection"
);
if (!dailyRankingSection || !dailyRankingSection.dailyRankings) {
throw "Cannot fetch daily ranking data";
}
const dailyRanking = dailyRankingSection.dailyRankings.find((ranking) =>
ranking.ranking && ranking.ranking.__typename === "DailyRanking"
);
if (
!dailyRanking || !dailyRanking.ranking ||
!dailyRanking.ranking.items || !dailyRanking.ranking.items.edges
) {
throw "Cannot fetch ranking data structure";
}
const rankingItems = dailyRanking.ranking.items.edges.map((edge) =>
edge.node
).filter((node) =>
node.__typename === "DailyRankingValidItem" && node.product
);
function parseComic(item) {
const series = item.product.series;
if (!series) return null;
const cover = series.squareThumbnailUriTemplate ||
series.horizontalThumbnailUriTemplate;
return {
id: series.databaseId,
title: series.title || "",
cover: cover
? cover.replace("{height}", "500").replace("{width}", "500")
: "",
tags: [],
description: `Ranking: ${item.rank} · Views: ${item.viewCount || "Unknown"
}`,
};
}
const comics = rankingItems.map(parseComic).filter((comic) =>
comic !== null
);
const result = {};
result["Daily Ranking"] = comics;
return result;
},
},
];
search = {
load: async (keyword, _, page) => {
if (!this.bearerToken || Date.now() > this.tokenExpiry) {
await this.fetchBearerToken();
}
const operationName = "SearchResult";
const response = await this.graphqlRequest(operationName, {
keyword,
});
const edges = response?.data?.search?.edges || [];
const pageInfo = response?.data?.search?.pageInfo || {};
const comics = edges.map(({ node }) => {
const cover = node.latestIssue?.thumbnailUriTemplate ||
node.thumbnailUriTemplate;
if (node.__typename === "Series") {
return new Comic({
id: node.databaseId,
title: node.title || "",
cover: this.replaceCoverUrl(cover),
extra: {
author: node.author?.name || "",
},
});
}
if (node.__typename === "MagazineLabel") {
return new Comic({
id: node.databaseId,
title: node.title || "",
cover: this.replaceCoverUrl(cover),
});
}
return null;
}).filter(Boolean);
return {
comics,
maxPage: pageInfo.hasNextPage ? (page || 1) + 1 : (page || 1),
endCursor: pageInfo.endCursor,
};
},
};
comic = {
loadInfo: async (id) => {
await this.ensureAuth();
const seriesData = await this.fetchSeriesDetail(id);
const chapters = await this.fetchEpisodes(id);
return new ComicDetails({
title: seriesData.title || "",
subtitle: seriesData.author?.name || "",
cover: this.replaceCoverUrl(seriesData.thumbnailUriTemplate),
description: seriesData.descriptionBanner?.text || "",
tags: {
"Author": [seriesData.author?.name || ""],
},
chapters,
});
},
loadEp: async (comicId, epId) => {
await this.ensureAuth();
const episodeId = this.normalizeEpisodeId(epId);
const episodeData = await this.fetchEpisodePages(episodeId);
if (!this.isEpisodeAccessible(episodeData)) {
await this.handleEpisodePurchase(episodeData);
return this.comic.loadEp(comicId, epId);
}
return this.buildImageUrls(episodeData);
},
onImageLoad: (url) => {
const [cleanUrl, token] = url.split("?token=");
return {
url: cleanUrl,
headers: { "X-Giga-Page-Image-Auth": token },
};
},
onClickTag: (namespace, tag) => {
if (namespace === "Author") {
return {
action: "search",
keyword: `${tag}`,
param: null,
};
}
throw "Unsupported tag namespace: " + namespace;
},
};
async ensureAuth() {
if (!this.bearerToken || Date.now() > this.tokenExpiry) {
await this.fetchBearerToken();
}
}
async graphqlRequest(operationName, variables) {
const payload = {
operationName,
variables,
query: GraphQLQueries[operationName],
};
const response = await Network.post(
`${this.apiBase}/graphql?opname=${operationName}`,
{
...this.headers,
"Authorization": `Bearer ${this.bearerToken}`,
"Accept": "application/json",
"X-APOLLO-OPERATION-NAME": operationName,
"Content-Type": "application/json",
},
JSON.stringify(payload),
);
if (response.status !== 200) throw `Invalid status: ${response.status}`;
return JSON.parse(response.body);
}
normalizeEpisodeId(epId) {
if (typeof epId === "object") return epId.id;
if (typeof epId === "string" && epId.includes("/")) {
return epId.split("/").pop();
}
return epId;
}
replaceCoverUrl(url) {
return (url || "").replace("{height}", "1500").replace(
"{width}",
"1500",
) || "";
}
async fetchBearerToken() {
const response = await Network.post(
`${this.apiBase}/user_account/access_token`,
this.headers,
"",
);
const { access_token, user_account_id } = JSON.parse(
response.body,
);
this.bearerToken = access_token;
this.userAccountId = user_account_id;
this.tokenExpiry = Date.now() + 3600000;
}
async fetchSeriesDetail(id) {
const response = await this.graphqlRequest("SeriesDetail", { id });
return response?.data?.series || {};
}
async fetchEpisodes(id) {
const response = await this.graphqlRequest(
"SeriesDetailEpisodeList",
{ id, episodeOffset: 0, episodeFirst: 1500, episodeSort: "NUMBER_ASC" },
);
const episodes = response?.data?.series?.episodes?.edges || [];
return episodes.reduce((chapters, { node }) => ({
...chapters,
[node.databaseId]: node.title || "",
}), {});
}
async fetchEpisodePages(episodeId) {
const response = await this.graphqlRequest(
"EpisodeViewerConditionallyCacheable",
{ episodeID: episodeId },
);
return response?.data?.episode || {};
}
isEpisodeAccessible({ purchaseInfo }) {
return purchaseInfo?.isFree || purchaseInfo?.hasPurchased ||
purchaseInfo?.hasRented;
}
async handleEpisodePurchase(episodeData) {
const { id, purchaseInfo } = episodeData;
const { purchasableViaOnetimeFree, rentable, unitPrice } = purchaseInfo ||
{};
if (purchasableViaOnetimeFree) await this.consumeOnetimeFree(id);
if (rentable) await this.rentChapter(id, unitPrice);
}
buildImageUrls({ pageImages, pageImageToken }) {
const validImages = pageImages.edges.flatMap((edge) => edge.node?.src)
.filter(Boolean);
return {
images: validImages.map((url) => `${url}?token=${pageImageToken}`),
};
}
async consumeOnetimeFree(episodeId) {
const response = await this.graphqlRequest("ConsumeOnetimeFree", {
input: { id: episodeId },
});
return response?.data?.consumeOnetimeFree?.isSuccess;
}
async rentChapter(episodeId, unitPrice, retryCount = 0) {
if (retryCount > 3) {
throw "Failed to rent chapter after multiple attempts.";
}
const response = await this.graphqlRequest("Rent", {
input: { id: episodeId, unitPrice },
});
if (response.errors?.[0]?.extensions?.code === "FAILED_TO_USE_POINT") {
await this.refreshAccount();
return this.rentChapter(episodeId, unitPrice, retryCount + 1);
}
this.userAccountId = response?.data?.rent?.userAccount?.databaseId;
return true;
}
async refreshAccount() {
this.deviceId = this.generateDeviceId();
this.bearerToken = this.userAccountId = null;
this.tokenExpiry = 0;
await this.fetchBearerToken();
await this.addUserDevice();
}
async addUserDevice() {
await this.graphqlRequest("AddUserDevice", {
input: {
deviceName: `Android ${21 + Math.floor(Math.random() * 14)}`,
modelName: `Device-${Math.random().toString(36).slice(2, 10)}`,
osName: `Android ${9 + Math.floor(Math.random() * 6)}`,
},
});
this.addUserDeviceCalled = true;
}
}
const GraphQLQueries = {
"SearchResult": `query SearchResult($after: String, $keyword: String!) {
search(after: $after, first: 50, keyword: $keyword, types: [SERIES,MAGAZINE_LABEL]) {
pageInfo { hasNextPage endCursor }
edges {
node {
__typename
... on Series { id databaseId title thumbnailUriTemplate author { name } }
... on MagazineLabel { id databaseId title thumbnailUriTemplate latestIssue { thumbnailUriTemplate } }
}
}
}
}`,
"SeriesDetail": `query SeriesDetail($id: String!) {
series(databaseId: $id) {
id databaseId title thumbnailUriTemplate
author { name } descriptionBanner { text }
hashtags serialUpdateScheduleLabel
}
}`,
"SeriesDetailEpisodeList":
`query SeriesDetailEpisodeList($id: String!, $episodeOffset: Int, $episodeFirst: Int, $episodeSort: ReadableProductSorting) {
series(databaseId: $id) {
episodes: readableProducts(types: [EPISODE,SPECIAL_CONTENT], first: $episodeFirst, offset: $episodeOffset, sort: $episodeSort) {
edges { node { databaseId title } }
}
}
}`,
"EpisodeViewerConditionallyCacheable":
`query EpisodeViewerConditionallyCacheable($episodeID: String!) {
episode(databaseId: $episodeID) {
id pageImages { edges { node { src } } } pageImageToken
purchaseInfo {
isFree hasPurchased hasRented
purchasableViaOnetimeFree rentable unitPrice
}
}
}`,
"ConsumeOnetimeFree":
`mutation ConsumeOnetimeFree($input: ConsumeOnetimeFreeInput!) {
consumeOnetimeFree(input: $input) { isSuccess }
}`,
"Rent": `mutation Rent($input: RentInput!) {
rent(input: $input) {
userAccount { databaseId }
}
}`,
"AddUserDevice": `mutation AddUserDevice($input: AddUserDeviceInput!) {
addUserDevice(input: $input) { isSuccess }
}`,
"HomeCacheable": `query HomeCacheable {
homeSections {
__typename
...DailyRankingSection
}
}
fragment DesignSectionImage on DesignSectionImage {
imageUrl width height
}
fragment SerialInfoIcon on SerialInfo {
isOriginal isIndies
}
fragment DailyRankingSeries on Series {
id databaseId publisherId title
horizontalThumbnailUriTemplate: subThumbnailUri(type: HORIZONTAL_WITH_LOGO)
squareThumbnailUriTemplate: subThumbnailUri(type: SQUARE_WITHOUT_LOGO)
isNewOngoing supportsOnetimeFree
serialInfo {
__typename ...SerialInfoIcon
status isTrial
}
jamEpisodeWorkType
}
fragment DailyRankingItem on DailyRankingItem {
__typename
... on DailyRankingValidItem {
product {
__typename
... on Episode {
id databaseId publisherId commentCount
series {
__typename ...DailyRankingSeries
}
}
... on SpecialContent {
publisherId linkUrl
series {
__typename ...DailyRankingSeries
}
}
}
badge { name label }
label rank viewCount
}
... on DailyRankingInvalidItem {
publisherWorkId
}
}
fragment DailyRanking on DailyRanking {
date firstPositionSeriesId
items {
edges {
node {
__typename ...DailyRankingItem
}
}
}
}
fragment DailyRankingSection on DailyRankingSection {
title
titleImage {
__typename ...DesignSectionImage
}
dailyRankings {
ranking {
__typename ...DailyRanking
}
}
}`,
};