mirror of
https://github.com/venera-app/venera-configs.git
synced 2025-09-27 08:27:24 +00:00
468 lines
13 KiB
JavaScript
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
|
|
}
|
|
}
|
|
}`,
|
|
};
|