Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ __pycache__
pytest.ini
/test_output
/test_input
/output_*
/output_*
/ai_output
44 changes: 42 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,22 @@ The `jncep` tool must be launched on the command-line. It has 4 commands:

## J-Novel Club account credentials

All the commands need some user credentials (email and password) in order to communicate with the J-Novel Club API. They are the same values as the ones used to log in to the J-Novel Club website with a browser.
All the commands need some user credentials in order to communicate with the J-Novel Club API. You can authenticate using either:
- **Password authentication**: Email and password (same values as used to log in to the J-Novel Club website)
- **OTP authentication**: Email and OTP code (One-Time Password, JNC Main only)

Those credentials can be passed directly on the command line using the `--email` and `--password` arguments to the __command__ (or subcommand for `track`), not the `jncep` tool directly. For example, using the `epub` command:
Those credentials can be passed directly on the command line using the `--email` and `--password` arguments (or `--otp` for OTP) to the __command__ (or subcommand for `track`), not the `jncep` tool directly. For example, using the `epub` command with password:

```console
jncep epub --email user@example.com --password "foo%bar666!" https://j-novel.club/series/tearmoon-empire
```

Or with OTP:

```console
jncep epub --email user@example.com --otp https://j-novel.club/series/tearmoon-empire
```

### Passing the credentials indirectly

It is also possible to pass the credentials indirectly in one of 2 ways:
Expand Down Expand Up @@ -97,6 +105,13 @@ export JNCEP_EMAIL=user@example.com
export JNCEP_PASSWORD="foo%bar666!"
```

Or to use OTP authentication:

```console
export JNCEP_EMAIL=user@example.com
export JNCEP_USE_OTP=true
```

Then, the same command as above can be simply launched as follows:

```console
Expand All @@ -105,6 +120,27 @@ jncep epub https://j-novel.club/series/tearmoon-empire

See the [general documentation on environment variables](#environment-variables-1).

### OTP (One-Time Password) authentication

OTP authentication is available as an alternative to password authentication for JNC Main (not available for JNC Nina). When using OTP:

1. Use the `--otp` flag instead of `--password`
2. The tool will generate an OTP code and display it
3. Visit https://j-novel.club/user/otp in your browser and enter the displayed code
4. The CLI will automatically detect when you've verified the code and complete the login

**Note:** OTP authentication requires interactive use - the CLI will wait (block) until you complete verification on the website. This is not suitable for fully automated scripts. For automation, use password authentication instead.

**Example:**

```console
jncep epub --email user@example.com --otp https://j-novel.club/series/tearmoon-empire
```

The OTP code will be displayed, and you'll need to enter it on the website. The CLI will poll for verification automatically.

You can also set `USE_OTP = true` in the configuration file or use the `JNCEP_USE_OTP` environment variable to enable OTP mode by default.

### JNC Nina account credentials

Starting with version 50, some support for [JNC Nina](https://jnc-nina.eu/) (J-Novel Club subsidiary for German and French translations) has been added.
Expand All @@ -116,6 +152,8 @@ Starting with version 50, some support for [JNC Nina](https://jnc-nina.eu/) (J-N

If the JNC Nina email is the same as the J-Novel Club email, it is possible to only use the main `--email` option (so no need to repeat). The JNC Nina password is not optional though.

**Note:** OTP authentication is not available for JNC Nina - only password authentication is supported.

One of the 2 options (for the main J-Novel Club website or for the JNC Nina website) must be present. For the `epub` or `update` commands, the choice of which credential to use is made depending on the series URL. When using `track sync`, the presence of credentials is used to decide on querying either website.

## Debugging mode
Expand Down Expand Up @@ -468,6 +506,8 @@ CONTENT Flag to indicate that the raw content of
output folder
CSS Path to custom CSS file for the EPUBs
EMAIL Login email for J-Novel Club account
USE_OTP Flag to use OTP authentication instead
of password (JNC Main only)
...
```

Expand Down
42 changes: 35 additions & 7 deletions jncep/cli/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,22 @@
help="Login password for JNC Nina account",
)

otp_option = credentials_group.option(
"--otp",
"use_otp",
is_flag=True,
envvar=f"{ENVVAR_PREFIX}USE_OTP",
help="Use OTP (One-Time Password) authentication instead of password (JNC Main only)",
)


def credentials_options(f):
# applied in reverse so they are visible in the help in the order below
for option in reversed(
[
login_option,
password_option,
otp_option,
login_nina_option,
password_nina_option,
process_credentials_options,
Expand All @@ -64,6 +73,7 @@ def wrapper(*args, **kwargs):
# options should always be present even if no value passed
email = kwargs.pop("email")
password = kwargs.pop("password")
use_otp = kwargs.pop("use_otp", False)
email_nina = kwargs.pop("email_nina")
password_nina = kwargs.pop("password_nina")

Expand All @@ -73,10 +83,10 @@ def wrapper(*args, **kwargs):
if not email_nina and password_nina:
email_nina = email

j_novel_credentials = email and password
j_novel_credentials = email and (password or use_otp)
nina_credentials = email_nina and password_nina

if nina_credentials and email and not password:
if nina_credentials and email and not password and not use_otp:
# assume the email only applies to nina
email = None

Expand All @@ -85,10 +95,23 @@ def wrapper(*args, **kwargs):
"You must pass either J-Novel Club or JNC Nina credentials"
)

if bool(email) != bool(password):
raise click.UsageError(
"You must pass both email and password for J-Novel Club account"
)
# Validate JNC Main credentials
if email:
if use_otp and password:
raise click.UsageError(
"Cannot use both password and OTP authentication. Use either --password or --otp."
)
if not use_otp and not password:
raise click.UsageError(
"You must pass either password (--password) or use OTP (--otp) for J-Novel Club account"
)
if use_otp:
# OTP mode: only email needed (OTP is JNC Main only, not Nina)
j_novel_credentials = True
elif not password:
raise click.UsageError(
"You must pass both email and password for J-Novel Club account"
)

if bool(email_nina) != bool(password_nina):
raise click.UsageError(
Expand All @@ -97,7 +120,12 @@ def wrapper(*args, **kwargs):

credential_mapping = {}
if j_novel_credentials:
credential_mapping[AltOrigin.JNC_MAIN] = (email, password)
if use_otp:
# OTP mode: (email, None, True)
credential_mapping[AltOrigin.JNC_MAIN] = (email, None, True)
else:
# Password mode: (email, password)
credential_mapping[AltOrigin.JNC_MAIN] = (email, password)
if nina_credentials:
credential_mapping[AltOrigin.JNC_NINA] = (email_nina, password_nina)

Expand Down
4 changes: 4 additions & 0 deletions jncep/config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from configparser import ConfigParser
from io import StringIO
import json
import os
from pathlib import Path
from datetime import datetime, timezone

from click import Context, get_app_dir

Expand Down Expand Up @@ -160,3 +162,5 @@ def __init__(self):
super().__init__(default_section=TOP_SECTION, interpolation=None)
# keys in upper case when reading (instead of default lower)
self.optionxform = lambda x: x.upper()


162 changes: 160 additions & 2 deletions jncep/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import dateutil.parser
from dateutil.relativedelta import relativedelta
from exceptiongroup import BaseExceptionGroup
import httpx
import trio

from . import epub, jncalts, jncapi, jncweb, namegen_utils, spec, utils
Expand Down Expand Up @@ -128,7 +129,16 @@ def __init__(self, config: jncalts.AltConfig, credentials):
self.config = config

self.api = jncapi.JNC_API(config)
self.email, self.password = credentials.get_credentials(config.ORIGIN)
creds = credentials.get_credentials(config.ORIGIN)
self.email = creds[0]
if len(creds) == 3 and creds[2] is True:
# OTP mode
self.password = None
self.use_otp = True
else:
# Password mode
self.password = creds[1]
self.use_otp = False

self.now = datetime.now(tz=timezone.utc)
# TODO only the member level is sufficient
Expand All @@ -142,7 +152,15 @@ async def __aenter__(self) -> JNCEPSession:
if session_dict is None:
session_dict = {}
if self.config.ORIGIN not in session_dict:
await self.login(self.email, self.password)
if self.use_otp:
# OTP is only available for JNC Main
if self.config.ORIGIN != jncalts.AltOrigin.JNC_MAIN:
raise Exception(
"OTP authentication is only available for JNC Main, not JNC Nina"
)
await self.login_with_otp()
else:
await self.login(self.email, self.password)
session_dict[self.config.ORIGIN] = self
_GLOBAL_SESSION_INSTANCE.set(session_dict)
return session_dict[self.config.ORIGIN]
Expand Down Expand Up @@ -196,6 +214,146 @@ async def login(self, email, password):
console.status("...")
return token

async def login_with_otp(self):
"""Login using OTP authentication flow."""
import random

display_email = self.email
if is_debug():
# hide for privacy in case the trace is copied to GH issue tracker
display_email = re.sub(
r"(?<=.)(.*)(?=@)", lambda x: "*" * len(x.group(1)), self.email
)
display_email = re.sub(
r"(?<=@.)(.*)(?=\.)", lambda x: "*" * len(x.group(1)), display_email
)

# Generate OTP
try:
console.status("Generating OTP code...")
otp_data = await self.api.generate_otp()
otp_code = otp_data["otp"]
proof = otp_data["proof"]
ttl = otp_data["ttl"]
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
console.error("Rate limit exceeded. Please wait a moment and try again.")
else:
console.error(f"Failed to generate OTP code: {e}")
raise
except Exception as e:
console.error(f"Failed to generate OTP code: {e}")
raise

# Display OTP code to user
console.info("")
console.info(
f"Visit [highlight]https://j-novel.club/user/otp[/] and enter the code:"
)
console.info(f"[highlight]{otp_code}[/]")
console.info("")

# Poll for verification
start_time = time.time()
poll_interval = 4.0 # seconds
backoff_count = 0
max_backoff = 16.0
otp_cleaned_up = False

try:
while True:
elapsed = time.time() - start_time
remaining = max(0, ttl - elapsed)

if remaining <= 0:
console.error("OTP code expired. Please generate a new code.")
break

# Update status message
status_msg = f"Waiting for verification... ({int(remaining)} seconds remaining)"
console.status(status_msg)

try:
# Check OTP status
token_data = await self.api.check_otp(otp_code, proof)

if token_data is not None:
# OTP verified!
self.api.token = token_data["id"]

# to be able to check subscription status
self.me = await self.api.me()
self.member_status = member_status(self)

emoji = ""
if console.is_advanced():
emoji = "\u26a1 "
console.info(
f"{emoji}Logged in to {self.config.ORIGIN} with email "
+ f"'[highlight]{display_email}[/]'"
)
console.status("...")

# Optional cleanup
try:
await self.api.delete_otp(otp_code, proof)
otp_cleaned_up = True
except Exception:
pass # Silently ignore cleanup errors

return token_data["id"]

# Not verified yet, continue polling
backoff_count = 0 # Reset backoff on successful check

except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
# Rate limited - exponential backoff
backoff_count += 1
if backoff_count == 1:
wait_time = 2.0
elif backoff_count == 2:
wait_time = 4.0
elif backoff_count == 3:
wait_time = 8.0
else:
wait_time = max_backoff

console.warning(
f"Rate limit exceeded. Waiting {int(wait_time)} seconds..."
)
await trio.sleep(wait_time)
continue
else:
# Other HTTP error
console.error(f"Verification failed: {e}")
raise

# Add small jitter to avoid synchronized polling
jitter = random.uniform(-0.5, 0.5)
await trio.sleep(poll_interval + jitter)

except KeyboardInterrupt:
# User cancelled - attempt cleanup
console.info("\nCancelling...")
if not otp_cleaned_up:
try:
await self.api.delete_otp(otp_code, proof)
except Exception:
pass # Silently ignore cleanup errors
raise

finally:
# Cleanup on timeout
if not otp_cleaned_up:
try:
await self.api.delete_otp(otp_code, proof)
except Exception:
pass # Silently ignore cleanup errors

# If we get here, timeout occurred
raise Exception("OTP verification timed out")

async def logout(self):
if self.api.is_logged_in:
try:
Expand Down
Loading