Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89c83ee5de | ||
|
|
69b5637bd6 | ||
|
|
d3192f1843 | ||
|
|
51749e05db | ||
|
|
5a5694f200 | ||
|
|
50d6c42207 | ||
|
|
bb1a938cc0 | ||
|
|
67c7ca8603 |
@@ -35,9 +35,14 @@ class InvalidUrlError(Exception):
|
|||||||
# reviewers catch drift.
|
# reviewers catch drift.
|
||||||
_PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
_PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
||||||
("patreon", re.compile(
|
("patreon", re.compile(
|
||||||
|
# Three creator URL shapes — bare (patreon.com/Atole), `c/`, and `cw/`
|
||||||
|
# (the "creator workspace" URL served once subscribed, see
|
||||||
|
# patreon_resolver._VANITY_RE). A trailing sub-path is allowed so a
|
||||||
|
# creator's inner page still derives the slug. Nav pages stay excluded.
|
||||||
r"^https?://(?:www\.)?patreon\.com/"
|
r"^https?://(?:www\.)?patreon\.com/"
|
||||||
r"(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c/)"
|
r"(?:cw/|c/)?"
|
||||||
r"(?P<slug>[^/?#]+)/?$",
|
r"(?!(?:home|search|messages|notifications|library|settings|posts)(?:[/?#]|$))"
|
||||||
|
r"(?P<slug>[^/?#]+)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)),
|
)),
|
||||||
("subscribestar", re.compile(
|
("subscribestar", re.compile(
|
||||||
|
|||||||
@@ -86,7 +86,16 @@ const PLATFORMS = {
|
|||||||
* script to decide whether to show the floating "Add as source" button.
|
* script to decide whether to show the floating "Add as source" button.
|
||||||
*/
|
*/
|
||||||
const PLATFORM_ARTIST_PATTERNS = {
|
const PLATFORM_ARTIST_PATTERNS = {
|
||||||
patreon: /^https?:\/\/(www\.)?patreon\.com\/(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c\/)[^/?#]+\/?$/i,
|
// Patreon serves the same creator under three URL shapes (see backend
|
||||||
|
// patreon_resolver._VANITY_RE): bare `patreon.com/Atole`, `c/` prefix, and
|
||||||
|
// `cw/` "creator workspace" — the last is the URL you land on once you're
|
||||||
|
// SUBSCRIBED, which is exactly when the button matters. Match all three, and
|
||||||
|
// drop the single-segment end-anchor so a creator's inner page
|
||||||
|
// (…/cw/Atole/posts, …/Atole/membership) also injects the button. Nav pages
|
||||||
|
// (home/search/…/posts permalink) stay excluded. Mirrors extension_service
|
||||||
|
// ._PLATFORM_PATTERNS — keep in sync (operator-flagged 2026-07-13: button
|
||||||
|
// vanished once subscribed because the old pattern only matched the bare root).
|
||||||
|
patreon: /^https?:\/\/(www\.)?patreon\.com\/(?:cw\/|c\/)?(?!(?:home|search|messages|notifications|library|settings|posts)(?:[\/?#]|$))[^/?#]+/i,
|
||||||
subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/i,
|
subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/i,
|
||||||
hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i,
|
hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i,
|
||||||
deviantart: /^https?:\/\/(www\.)?deviantart\.com\/(?!home$|watch\b|tag\b|browse\b)[^/?#]+\/?$/i,
|
deviantart: /^https?:\/\/(www\.)?deviantart\.com\/(?!home$|watch\b|tag\b|browse\b)[^/?#]+\/?$/i,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "FabledCurator",
|
"name": "FabledCurator",
|
||||||
"version": "1.0.7",
|
"version": "1.0.8",
|
||||||
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
||||||
|
|
||||||
"browser_specific_settings": {
|
"browser_specific_settings": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fabledcurator-extension",
|
"name": "fabledcurator-extension",
|
||||||
"version": "1.0.7",
|
"version": "1.0.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Firefox extension for FabledCurator",
|
"description": "Firefox extension for FabledCurator",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -10,6 +10,6 @@
|
|||||||
"sign": "web-ext sign --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore --channel=unlisted --api-key=$WEB_EXT_API_KEY --api-secret=$WEB_EXT_API_SECRET"
|
"sign": "web-ext sign --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore --channel=unlisted --api-key=$WEB_EXT_API_KEY --api-secret=$WEB_EXT_API_SECRET"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"web-ext": "^8.0.0"
|
"web-ext": "^10.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="fc-topnav" :class="{ 'fc-topnav--chrome': hasStickyChrome }">
|
<header ref="navEl" class="fc-topnav" :class="{ 'fc-topnav--chrome': hasStickyChrome }">
|
||||||
<div class="fc-nav-left">
|
<div class="fc-nav-left">
|
||||||
<RouterLink :to="FRONT_DOOR" class="fc-brand" aria-label="FabledCurator home">
|
<RouterLink :to="FRONT_DOOR" class="fc-brand" aria-label="FabledCurator home">
|
||||||
<img src="/favicon.svg" alt="" class="fc-brand__glyph" width="22" height="22" />
|
<img src="/favicon.svg" alt="" class="fc-brand__glyph" width="22" height="22" />
|
||||||
@@ -64,14 +64,32 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import router, { FRONT_DOOR } from '../router.js'
|
import router, { FRONT_DOOR } from '../router.js'
|
||||||
import { useSystemStore } from '../stores/system.js'
|
import { useSystemStore } from '../stores/system.js'
|
||||||
import PipelineStatusChip from './PipelineStatusChip.vue'
|
import PipelineStatusChip from './PipelineStatusChip.vue'
|
||||||
|
|
||||||
const system = useSystemStore()
|
const system = useSystemStore()
|
||||||
onMounted(() => system.refreshHealth())
|
|
||||||
|
// Publish the nav's REAL height as --fc-nav-h so full-height workspaces
|
||||||
|
// (Explore/Subscriptions) and sticky sub-headers pin to it exactly instead of a
|
||||||
|
// hardcoded 64px that Vuetify 4's MD3 sizing broke — the Explore breadcrumb was
|
||||||
|
// tucking under a taller nav (#1481). ResizeObserver keeps it live as the nav
|
||||||
|
// reflows (per-view teleported actions, mobile breakpoint, chip state changes).
|
||||||
|
const navEl = ref(null)
|
||||||
|
let navRO = null
|
||||||
|
onMounted(() => {
|
||||||
|
system.refreshHealth()
|
||||||
|
if (navEl.value && 'ResizeObserver' in window) {
|
||||||
|
navRO = new ResizeObserver(() => {
|
||||||
|
const h = navEl.value?.offsetHeight
|
||||||
|
if (h) document.documentElement.style.setProperty('--fc-nav-h', `${h}px`)
|
||||||
|
})
|
||||||
|
navRO.observe(navEl.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => { navRO?.disconnect() })
|
||||||
|
|
||||||
// Views that pin a sticky sub-header (filter bar / tabs) directly under the nav
|
// Views that pin a sticky sub-header (filter bar / tabs) directly under the nav
|
||||||
// declare `meta.stickyChrome`. On those, the nav doesn't fade to transparent at
|
// declare `meta.stickyChrome`. On those, the nav doesn't fade to transparent at
|
||||||
@@ -127,12 +145,17 @@ const health = computed(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
/* Obsidian (#14171A = 20,23,26) gradient fade — content scrolls under it. */
|
/* Obsidian (#14171A) fade — content scrolls under it. Holds high (0.92 →
|
||||||
|
0.84) through the top half, then eases to transparent over the bottom
|
||||||
|
quarter so it tails off softly instead of a straight line to a hard edge
|
||||||
|
(operator 2026-07-13). Shared --fc-chrome-rgb keeps it in sync with the
|
||||||
|
sub-header continuation. */
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
rgba(20, 23, 26, 0.92) 0%,
|
rgba(var(--fc-chrome-rgb), 0.92) 0%,
|
||||||
rgba(20, 23, 26, 0.65) 60%,
|
rgba(var(--fc-chrome-rgb), 0.84) 50%,
|
||||||
rgba(20, 23, 26, 0) 100%
|
rgba(var(--fc-chrome-rgb), 0.55) 75%,
|
||||||
|
rgba(var(--fc-chrome-rgb), 0) 100%
|
||||||
);
|
);
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
-webkit-backdrop-filter: blur(2px);
|
-webkit-backdrop-filter: blur(2px);
|
||||||
@@ -147,7 +170,7 @@ const health = computed(() => {
|
|||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
rgba(var(--fc-chrome-rgb), 0.92) 0%,
|
rgba(var(--fc-chrome-rgb), 0.92) 0%,
|
||||||
rgba(var(--fc-chrome-rgb), 0.72) 55%,
|
rgba(var(--fc-chrome-rgb), 0.84) 60%,
|
||||||
rgba(var(--fc-chrome-rgb), var(--fc-chrome-seam)) 100%
|
rgba(var(--fc-chrome-rgb), var(--fc-chrome-seam)) 100%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ function pushFilter(mutate) {
|
|||||||
frosted block pinned directly under the 64px TopNav and continuous with it. */
|
frosted block pinned directly under the 64px TopNav and continuous with it. */
|
||||||
.fc-filterbar-wrap {
|
.fc-filterbar-wrap {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 64px;
|
top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
/* Attach to the TopNav: cancel the v-container's top padding (pt-2 = 8px)
|
/* Attach to the TopNav: cancel the v-container's top padding (pt-2 = 8px)
|
||||||
so the bar sits flush at 64px even at scroll 0 — without this it detaches
|
so the bar sits flush at 64px even at scroll 0 — without this it detaches
|
||||||
@@ -331,6 +331,20 @@ function pushFilter(mutate) {
|
|||||||
.fc-filterbar-wrap :deep(.v-btn-group) {
|
.fc-filterbar-wrap :deep(.v-btn-group) {
|
||||||
background-color: rgba(20, 23, 26, 0.72);
|
background-color: rgba(20, 23, 26, 0.72);
|
||||||
}
|
}
|
||||||
|
/* Media toggle (All / Images / Videos) as ONE cohesive segmented control.
|
||||||
|
FC's global VBtn { rounded: 'pill' } default made Vuetify 4 pill-round each
|
||||||
|
SEGMENT individually, so the rounded ends collided at the joins — the shapes
|
||||||
|
landed awkwardly on the button edges (operator 2026-07-13). Square the inner
|
||||||
|
segments (over the pill utility's !important) and clip the group to a single
|
||||||
|
8px outline (matches the chips/tiles rounding elsewhere in the app). Radius
|
||||||
|
only — no height change, so the bar height and nav offset are untouched. */
|
||||||
|
.fc-filterbar-wrap :deep(.v-btn-toggle) {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.fc-filterbar-wrap :deep(.v-btn-toggle .v-btn) {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
.fc-filterbar__search { max-width: 320px; min-width: 200px; }
|
.fc-filterbar__search { max-width: 320px; min-width: 200px; }
|
||||||
.fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
.fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||||
/* The tag chips' bodies toggle include/exclude — signal they're clickable. */
|
/* The tag chips' bodies toggle include/exclude — signal they're clickable. */
|
||||||
|
|||||||
@@ -77,7 +77,20 @@
|
|||||||
legible tabs/controls over scrolling content), lower it for a lighter fade. */
|
legible tabs/controls over scrolling content), lower it for a lighter fade. */
|
||||||
:root {
|
:root {
|
||||||
--fc-chrome-rgb: 20, 23, 26; /* obsidian #14171A — matches the TopNav */
|
--fc-chrome-rgb: 20, 23, 26; /* obsidian #14171A — matches the TopNav */
|
||||||
--fc-chrome-seam: 0.46; /* alpha where the nav hands off to the sub-header */
|
/* Alpha where the nav hands off to the sub-header — also the "hold" level of
|
||||||
|
the fade. The chrome stays fairly opaque (0.92 → this) through the bulk of
|
||||||
|
its height, then drops to transparent in a small eased section at the very
|
||||||
|
bottom (see the multi-stop gradients), so it reads as a slow falloff that
|
||||||
|
tails off softly rather than a straight line to a hard edge (operator
|
||||||
|
2026-07-13). Raise for heavier/more-legible chrome, lower for a lighter fade. */
|
||||||
|
--fc-chrome-seam: 0.68;
|
||||||
|
/* Actual TopNav height, measured live (ResizeObserver in TopNav.vue) and used
|
||||||
|
by full-height workspaces (Explore/Subscriptions: calc(100vh - var)) and by
|
||||||
|
every sticky sub-header pinned beneath the nav (top: var). This was a
|
||||||
|
hardcoded 64px in ~6 places; Vuetify 4's MD3 sizing made the real nav a
|
||||||
|
different height, so the Explore workspace overflowed and its breadcrumb
|
||||||
|
tucked under the nav (#1481). This fallback is only used pre-measure. */
|
||||||
|
--fc-nav-h: 64px;
|
||||||
}
|
}
|
||||||
/* Applied to a sticky sub-header so it continues the nav's fade instead of
|
/* Applied to a sticky sub-header so it continues the nav's fade instead of
|
||||||
restarting it. Percentage stops so the fade always spans the element's height
|
restarting it. Percentage stops so the fade always spans the element's height
|
||||||
@@ -85,9 +98,16 @@
|
|||||||
controls legible as the fill thins toward transparent — the solid-surface
|
controls legible as the fill thins toward transparent — the solid-surface
|
||||||
bars it replaces had none, so it must live here. */
|
bars it replaces had none, so it must live here. */
|
||||||
.fc-chrome-continues {
|
.fc-chrome-continues {
|
||||||
|
/* Continues the nav's fade: HOLDS near the seam alpha through the first ~55%
|
||||||
|
(subtle), then eases down to transparent over the last ~45% with an
|
||||||
|
intermediate stop so the tail is soft — no hard line at the bottom edge
|
||||||
|
(operator 2026-07-13). Percentage stops keep the shape spanning the
|
||||||
|
element's height (survives the filter bar's expanding refine panel). */
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
rgba(var(--fc-chrome-rgb), var(--fc-chrome-seam)) 0%,
|
rgba(var(--fc-chrome-rgb), var(--fc-chrome-seam)) 0%,
|
||||||
|
rgba(var(--fc-chrome-rgb), 0.60) 55%,
|
||||||
|
rgba(var(--fc-chrome-rgb), 0.28) 82%,
|
||||||
rgba(var(--fc-chrome-rgb), 0) 100%
|
rgba(var(--fc-chrome-rgb), 0) 100%
|
||||||
);
|
);
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ function clearFilter(key) {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-browse__head {
|
.fc-browse__head {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 64px; /* directly under AppShell's 64px sticky TopNav */
|
top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
/* Background is the shared .fc-chrome-continues fade — it continues the nav's
|
/* Background is the shared .fc-chrome-continues fade — it continues the nav's
|
||||||
gradient instead of a solid surface band (operator 2026-07-13). */
|
gradient instead of a solid surface band (operator 2026-07-13). */
|
||||||
|
|||||||
@@ -286,10 +286,13 @@ onUnmounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
|
|
||||||
/* Full-height workspace under the sticky top nav. */
|
/* Full-height workspace under the sticky top nav. --fc-nav-h is the nav's REAL
|
||||||
|
measured height (set by TopNav) — a hardcoded 64px here overflowed the
|
||||||
|
viewport under Vuetify 4's taller nav and tucked the breadcrumb under it
|
||||||
|
(#1481). Panes scroll internally, so an exact fit keeps everything on screen. */
|
||||||
.fc-ex {
|
.fc-ex {
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
height: calc(100vh - 64px);
|
height: calc(100vh - var(--fc-nav-h, 64px));
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ onMounted(() => {
|
|||||||
band (operator 2026-07-13). */
|
band (operator 2026-07-13). */
|
||||||
.fc-series__head {
|
.fc-series__head {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 64px;
|
top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
continues the nav's gradient across the strip (operator 2026-07-13). -->
|
continues the nav's gradient across the strip (operator 2026-07-13). -->
|
||||||
<v-tabs
|
<v-tabs
|
||||||
v-model="tab" color="accent" class="mb-4 fc-chrome-continues"
|
v-model="tab" color="accent" class="mb-4 fc-chrome-continues"
|
||||||
style="position: sticky; top: 64px; z-index: 4;"
|
style="position: sticky; top: var(--fc-nav-h, 64px); z-index: 4;"
|
||||||
>
|
>
|
||||||
<v-tab value="overview">Overview</v-tab>
|
<v-tab value="overview">Overview</v-tab>
|
||||||
<v-tab value="activity">Activity</v-tab>
|
<v-tab value="activity">Activity</v-tab>
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ const { tab } = useTabQuery(VALID_TABS, 'subscriptions')
|
|||||||
/* Fixed-height hub: the tabs (and each tab's sticky control bar) stay
|
/* Fixed-height hub: the tabs (and each tab's sticky control bar) stay
|
||||||
put while ONLY the tab content scrolls — previously the whole view
|
put while ONLY the tab content scrolls — previously the whole view
|
||||||
scrolled instead of just the subscription list (operator-flagged
|
scrolled instead of just the subscription list (operator-flagged
|
||||||
2026-05-28). 64px = the TopNav height (AppShell .fc-content pad-top). */
|
2026-05-28). --fc-nav-h = the TopNav's real measured height (#1481). */
|
||||||
height: calc(100vh - 64px);
|
height: calc(100vh - var(--fc-nav-h, 64px));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Unit tests for ExtensionService._derive — the URL → (platform, slug)
|
||||||
|
parser that gates the browser extension's "Add as source" button and pulls
|
||||||
|
the creator slug on probe/add.
|
||||||
|
|
||||||
|
Regression cover for #1485: Patreon serves the same creator under three URL
|
||||||
|
shapes — bare `patreon.com/Atole`, `c/`, and `cw/` (the "creator workspace"
|
||||||
|
URL you land on once SUBSCRIBED). The button used to vanish while subscribed
|
||||||
|
because the pattern only matched the bare root and excluded `c/`.
|
||||||
|
|
||||||
|
_derive is pure URL parsing (no DB / no async), so a session-less instance is
|
||||||
|
fine to exercise directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.app.services.extension_service import (
|
||||||
|
ExtensionService,
|
||||||
|
InvalidUrlError,
|
||||||
|
UnknownPlatformError,
|
||||||
|
)
|
||||||
|
|
||||||
|
_svc = ExtensionService(None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url, slug",
|
||||||
|
[
|
||||||
|
# All three Patreon creator prefixes resolve to the same vanity slug.
|
||||||
|
("https://www.patreon.com/Atole", "Atole"),
|
||||||
|
("https://www.patreon.com/c/Atole", "Atole"),
|
||||||
|
("https://www.patreon.com/cw/Atole", "Atole"), # subscribed-view URL
|
||||||
|
# A creator's inner page still derives the slug (trailing sub-path).
|
||||||
|
("https://www.patreon.com/cw/Atole/posts", "Atole"),
|
||||||
|
("https://www.patreon.com/Atole/membership", "Atole"),
|
||||||
|
("https://patreon.com/c/Atole", "Atole"), # bare host, no www
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_derive_patreon_creator_urls(url, slug):
|
||||||
|
platform, got = _svc._derive(url)
|
||||||
|
assert platform == "patreon"
|
||||||
|
assert got == slug
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url",
|
||||||
|
[
|
||||||
|
# Patreon's own nav pages must never read as a creator slug.
|
||||||
|
"https://www.patreon.com/home",
|
||||||
|
"https://www.patreon.com/settings",
|
||||||
|
"https://www.patreon.com/search",
|
||||||
|
"https://www.patreon.com/messages",
|
||||||
|
"https://www.patreon.com/library",
|
||||||
|
"https://www.patreon.com/notifications",
|
||||||
|
"https://www.patreon.com/posts/12345", # post permalink
|
||||||
|
"https://www.patreon.com/settings/billing", # nav sub-page
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_derive_patreon_nav_pages_rejected(url):
|
||||||
|
with pytest.raises(UnknownPlatformError):
|
||||||
|
_svc._derive(url)
|
||||||
|
|
||||||
|
|
||||||
|
def test_derive_rejects_missing_scheme():
|
||||||
|
with pytest.raises(InvalidUrlError):
|
||||||
|
_svc._derive("patreon.com/Atole")
|
||||||
Reference in New Issue
Block a user