From 0fbdfb90de7f8b5e1b46a26c4c40270ec1bdb45d Mon Sep 17 00:00:00 2001 From: Essential Randomness Date: Tue, 30 Dec 2025 01:22:13 -0800 Subject: [PATCH 1/3] add method to fetch tags with same meaning --- src/tags/index.ts | 2 + src/tags/page-getters.ts | 8 + .../Charlie Magne ! Morningstar/index.html | 342 ++ .../Charlie Magne ! Morningstar/works.html | 2869 +++++++++++++++++ tests/tags.test.ts | 132 + types/entities.ts | 1 + 6 files changed, 3354 insertions(+) create mode 100644 tests/mocks/data/ao3/tags/Charlie Magne ! Morningstar/index.html create mode 100644 tests/mocks/data/ao3/tags/Charlie Magne ! Morningstar/works.html diff --git a/src/tags/index.ts b/src/tags/index.ts index 8095bbb2..05b4ffc4 100644 --- a/src/tags/index.ts +++ b/src/tags/index.ts @@ -7,6 +7,7 @@ import { getParentTags, getChildTags, getSubTags, + getTagsWithSameMeaning, } from "./page-getters"; import { getTagId, getTagNameFromFeed } from "./works-feed-getters"; import { @@ -79,6 +80,7 @@ export const getTag = async ({ parentTags: getParentTags(tagPage), childTags: getChildTags(tagPage), subTags: getSubTags(tagPage), + tagsWithSameMeaning: getTagsWithSameMeaning(tagPage), }; }; diff --git a/src/tags/page-getters.ts b/src/tags/page-getters.ts index 64495737..09955dfb 100644 --- a/src/tags/page-getters.ts +++ b/src/tags/page-getters.ts @@ -81,3 +81,11 @@ export const getSubTags = ($tagPage: TagPage) => { }); return subTags; }; + +export const getTagsWithSameMeaning = ($tagPage: TagPage) => { + const tagsWithSameMeaning: string[] = []; + $tagPage(".synonym ul.tags li").each((_, element) => { + tagsWithSameMeaning.push($tagPage(element).text()); + }); + return tagsWithSameMeaning; +}; diff --git a/tests/mocks/data/ao3/tags/Charlie Magne ! Morningstar/index.html b/tests/mocks/data/ao3/tags/Charlie Magne ! Morningstar/index.html new file mode 100644 index 00000000..413fb8f0 --- /dev/null +++ b/tests/mocks/data/ao3/tags/Charlie Magne ! Morningstar/index.html @@ -0,0 +1,342 @@ + + + + + + + + + + + + + + + + Charlie Magne | Morningstar | Archive of Our Own + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + +
+ + + + +
+ +
+ +
+
+

Charlie Magne | Morningstar

+
+ +
+ + + + + +

This tag belongs to the Character Category. + It's a canonical tag. You can use it to filter works and to filter bookmarks. +

+ + + +
+

Parent tags (more general):

+ +
+ + +
+

Tags with the same meaning:

+ +
+ +
+

Metatags:

+ +
+ + + +
+

Child tags (displaying the first 300 of each type):

+
+

Relationships:

+ +
+
+ +
+ + +
+
+ +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/mocks/data/ao3/tags/Charlie Magne ! Morningstar/works.html b/tests/mocks/data/ao3/tags/Charlie Magne ! Morningstar/works.html new file mode 100644 index 00000000..b36dae1b --- /dev/null +++ b/tests/mocks/data/ao3/tags/Charlie Magne ! Morningstar/works.html @@ -0,0 +1,2869 @@ + + + + + + + + + + + + + + + + Charlie Magne | Morningstar - Works | Archive of Our Own + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + +
+ + + + +
+ +
+ + + +

+ 1 - 20 of 22,932 Works in Charlie Magne | Morningstar +

+ + + + + + + +

Navigation

+ + +

Listing Works

+
    +
  1. + + +
    + +

    + As For Your Punishment + by + + + + + + + + +

    + +
    + Fandoms: + Hazbin Hotel (Cartoon) +   +
    + + + +

    29 Dec 2025

    +
    + + +
    Tags
    + + + +
    Summary
    +
    +

    When Alastor gets taken prisoner by the Vees, Vox is more than happy to claim every bit of his victory. What starts as fucking around with no strings attached reveals a surprising emotional twist, and both overlords must outmaneuver fate in order to change their destinies.

    +
    + + + + +
    +
    Language:
    +
    English
    +
    Words:
    +
    120,769
    +
    Chapters:
    +
    29/?
    + + + +
    Comments:
    +
    460
    + +
    Kudos:
    +
    1,489
    + +
    Bookmarks:
    +
    218
    + +
    Hits:
    +
    40,719
    + +
    + + +
  2. +
  3. + + + + + +
    Tags
    + + + +
    Summary
    +
    +

    When Emily tries to expose the Exterminations to the Archangels, she is forced into falling by Lute, Adam's right hand, and the First Man himself. Now a Fallen Angel and in the hands of the Carmine's in Hell, Emily must figure out a way to expose Heaven's lies while quelling the unrest that is growing in Hell.

    +
    + + + + +
    +
    Language:
    +
    English
    +
    Words:
    +
    13,079
    +
    Chapters:
    +
    2/?
    + + + +
    Comments:
    +
    4
    + +
    Kudos:
    +
    10
    + +
    Bookmarks:
    +
    2
    + +
    Hits:
    +
    199
    + +
    + + +
  4. +
  5. + + +
    + +

    + How Charlie met Emily + by + + + + + + + + +

    + +
    + Fandoms: + Hazbin Hotel (Cartoon) +   +
    + + + +

    29 Dec 2025

    +
    + + +
    Tags
    + + + +
    Summary
    +
    +

    [29/12/2025: Chapter 1 has been rewritten and split into 3 new chapters.]

    +

    How one small decision made by Adam changes the course of history, as Emily and Charlie find themselves bound together by marriage.

    +

    How did this come to be?

    +

    What about a certain ex-exorcist?

    +

    What do Sera and Lucifer think of this particular arrangement?

    +

    How did Charlie come to meet her future wife, Emily?

    +
    + +
    Series
    + + + + +
    +
    Language:
    +
    English
    +
    Words:
    +
    21,460
    +
    Chapters:
    +
    7/7
    + + + +
    Comments:
    +
    172
    + +
    Kudos:
    +
    296
    + +
    Bookmarks:
    +
    67
    + +
    Hits:
    +
    9,900
    + +
    + + +
  6. +
  7. + + + + + +
    Tags
    + + + +
    Summary
    +
    +

    Zelda and her family officially started living in the hotel but stay connected with their Earthly lives. With her identity revealed, Zelda will meet new demon, including royals, and Sinners at the hotel, along with being with old one as her life takes one turn after another. However, things are fully peaceful as the Vees has their own agenda since the battle at the Hotel.

    +
    + +
    Series
    + + + + +
    +
    Language:
    +
    English
    +
    Words:
    +
    134,970
    +
    Chapters:
    +
    25/?
    + + + +
    Comments:
    +
    31
    + +
    Kudos:
    +
    34
    + +
    Bookmarks:
    +
    5
    + +
    Hits:
    +
    2,245
    + +
    + + +
  8. +
  9. + + +
    + +

    + Im your Omega + by + + + + + + + + +

    + +
    + Fandoms: + Hazbin Hotel (Cartoon) +   +
    + + + +

    29 Dec 2025

    +
    + + +
    Tags
    + + + +
    Summary
    +
    +

    When Vox said, "I'm your omega," I lost it, so here is a fanfic about Radiostatic Alastor x Vox. Vox is an omega, and Alastor is an Alpha.

    +

    Vox is used to controlling the game, but one leak, two betrayals, and everything starts to unravel. Outed as an Omega in a world that never forgives weakness, he must navigate enemies, traitors, and shifting loyalties to stay on top. And when Alastor steps in, it’s not a rescue, it’s a reminder of just how far he’s fallen.

    +

    Radiostatic

    +

    Season 2 is not gonna take a part in this fanfic. Its more like a What if it was an omegaverse kinda world, but some elements of it will appear. ;))

    +
    + + + + +
    +
    Language:
    +
    English
    +
    Words:
    +
    39,854
    +
    Chapters:
    +
    15/?
    + + + +
    Comments:
    +
    108
    + +
    Kudos:
    +
    340
    + +
    Bookmarks:
    +
    39
    + +
    Hits:
    +
    5,613
    + +
    + + +
  10. +
  11. + + + + + +
    Tags
    + + + +
    Summary
    +
    +

    When Vox’s doomsday weapon misfires, Shok.wav throws himself into the angelic beam to shield Valentino and Velvette. The damage is irrepairable, and the drone dies with the Media Overlord by his side. His attempts to save him are futile.

    +

    Suddenly, everything is so easy to blame on the other two Vees. If it wasn't for them, his pet would be alive and well. Vox tears his family apart with his own claws in blind rage, only snapping back to reality when the city’s billboards light up with memories of what he just destroyed.

    +

    Two months later, a broken, glitching shell of a former Overlord is found motionless in an alleway, eroding in the rain. Charlie refuses to let Vox rot away. Alastor is much less pleased. Yes this is a RadioStatic fic

    +

    Or;

    +

    I want this dumbass to suffer more cuz I feel like he got off easy. So here you go, my weird ass AU/rewrite. Before the finale came out I was worried that in order to make Vox truly realize how much he's lost, both Velvette and Valentino would need to end up dead in the final battle. The one thing that can't be replaced- people. And now Vox's pet died protecting his friends from himself, and then those very same people end up getting killed in a fit of rage.

    +
    + + + + +
    +
    Language:
    +
    English
    +
    Words:
    +
    143,546
    +
    Chapters:
    +
    31/?
    + + + +
    Comments:
    +
    552
    + +
    Kudos:
    +
    1,506
    + +
    Bookmarks:
    +
    228
    + +
    Hits:
    +
    33,403
    + +
    + + +
  12. +
  13. + + +
    + +

    + Arsenic and Old Wool + by + + + + + + + + +

    + +
    + Fandoms: + Hazbin Hotel (Cartoon) +   +
    + + + +

    29 Dec 2025

    +
    + + +
    Tags
    + + + +
    Summary
    +
    +

    Lucifer woke up alone and considering he hadn't gone to bed that way, it only gets worse from there.

    +
    + +
    Series
    + + + + +
    +
    Language:
    +
    English
    +
    Words:
    +
    28,631
    +
    Chapters:
    +
    6/?
    + + + +
    Comments:
    +
    188
    + +
    Kudos:
    +
    621
    + +
    Bookmarks:
    +
    37
    + +
    Hits:
    +
    11,029
    + +
    + + +
  14. +
  15. + + + + + +
    Tags
    + + + +
    Summary
    +
    +

    When the cursed seer Cassandra lands in hell she does so with a plan. While she may not be able to tell anyone of the future she sees, lest they think her mad, she can make them sing of it.

    +

    So begins my reaction fic, where each of the characters sing and react to the Hazbin hotel and hellaverse songs, often without context.

    +
    + + + + +
    +
    Language:
    +
    English
    +
    Words:
    +
    10,242
    +
    Chapters:
    +
    6/?
    + + + +
    Comments:
    +
    6
    + +
    Kudos:
    +
    48
    + +
    Bookmarks:
    +
    19
    + +
    Hits:
    +
    1,509
    + +
    + + +
  16. +
  17. + + + + + +
    Tags
    + + + +
    Summary
    +
    +

    En el tejado del Hazbin Hotel, Adam tiene a Charlie Morningstar a su merced y Vaggie atrapada. Pero un portal interdimensional se abre, trayendo a Charlie Sefirot, la guerrera suprema y Exorcista más poderosa de toda la creación. Ahora, el Infierno será testigo de la colisión de dos Charlies, ángeles y demonios, y la lucha definitiva entre poder, redención y crueldad.

    +
    + + + + +
    +
    Language:
    +
    Español
    +
    Words:
    +
    83,139
    +
    Chapters:
    +
    85/?
    + + + +
    Comments:
    +
    10
    + +
    Kudos:
    +
    4
    + + +
    Hits:
    +
    254
    + +
    + + +
  18. +
  19. + + + + + +
    Tags
    + + + +
    Summary
    +
    +

    The fight for the Hotel is interrupted by an ancient evil that is greater than any they have faced before. Can Adam and Charlie work together? And what will Charlie find out to shake her worldview in the meantime?

    +

    Join us as we journey through the time that Day Breaks, and Charlie learns just who her parents really are.

    +
    + + + + +
    +
    Language:
    +
    English
    +
    Words:
    +
    43,392
    +
    Chapters:
    +
    8/?
    + + + +
    Comments:
    +
    134
    + +
    Kudos:
    +
    115
    + +
    Bookmarks:
    +
    31
    + +
    Hits:
    +
    4,178
    + +
    + + +
  20. +
  21. + + +
    + +

    + Sinsmas Memories + by + + + Anonymous + + + + + +

    + +
    + Fandoms: + Hazbin Hotel (Cartoon) +   +
    + + + +

    29 Dec 2025

    +
    + + +
    Tags
    + + + +
    Summary
    +
    +

    It's Sinsmas again, and that leaves our boys with memories to ignore again.

    +
    + + + + +
    +
    Language:
    +
    English
    +
    Words:
    +
    2,073
    +
    Chapters:
    +
    2/2
    + + +
    Collections:
    +
    2
    + + + +
    Hits:
    +
    0
    + +
    + + +
  22. +
  23. + + +
    + +

    + even if it's a false god + by + + + + + + + + +

    + +
    + Fandoms: + Hazbin Hotel (Cartoon) +   +
    + + + +

    29 Dec 2025

    +
    + + +
    Tags
    + + + +
    Summary
    +
    +

    All Adam wanted was to go back to Heaven once he woke up after the Extermination. However, to his misfortune, Sera punished him by making him the new goddamn Supervisor of the Hazbin Hotel. He soon starts to feel the consequences of living in Hell devoid of God's grace, and he goes crazy driven by the damn need, the urge to taste something divine.

    +

    Luckily for him, Lucifer is right there, ready to lend a helping hand.

    +

    Or

    +

    Adam suffers the consequences of a year of living in Hell deprived of God's grace. Lucifer offers a helping hand.

    +
    + + + + +
    +
    Language:
    +
    English
    +
    Words:
    +
    28,959
    +
    Chapters:
    +
    3/6
    + + + +
    Comments:
    +
    18
    + +
    Kudos:
    +
    57
    + +
    Bookmarks:
    +
    11
    + +
    Hits:
    +
    589
    + +
    + + +
  24. +
  25. + + +
    + +

    + Sharks Call Their Babies "Pups" + by + + + + + + + + +

    + +
    + Fandoms: + Hazbin Hotel (Cartoon) +   +
    + + + +

    29 Dec 2025

    +
    + + +
    Tags
    + + + +
    Summary
    +
    +

    Vox liked to believe he always made the best decisions for any situation. So why did he feel he was going to regret this later?

    +

    AKA, a human child ends up in Hell and Vox finds them. Bringing old emotions to the surface, and giving Vox something he didn't realize he needed.

    +
    + + + + +
    +
    Language:
    +
    English
    +
    Words:
    +
    3,738
    +
    Chapters:
    +
    1/?
    + + + +
    Comments:
    +
    2
    + +
    Kudos:
    +
    2
    + + +
    Hits:
    +
    7
    + +
    + + +
  26. +
  27. + + + + + +
    Tags
    + + + +
    Summary
    +
    +

    Lucifer and Charlie fuck after Charlie Kirk ends up in hell and thinks he wasn't meant to be there.

    +
    + +
    Series
    + + + + +
    +
    Language:
    +
    English
    +
    Words:
    +
    715
    +
    Chapters:
    +
    3/3
    + + + + + +
    Hits:
    +
    3
    + +
    + + +
  28. +
  29. + + +
    + +

    + Let the show begin + by + + + + + + + + +

    + +
    + Fandoms: + Hazbin Hotel (Cartoon) +   +
    + + + +

    29 Dec 2025

    +
    + + +
    Tags
    + + + +
    Summary
    +
    +

    Every screen on Earth is hijacked by a mysterious entity? Why? To watch Heaven and Hell, which are apparently real! This is going to be very entertaining

    +
    + +
    Series
    + + + + +
    +
    Language:
    +
    English
    +
    Words:
    +
    69,544
    +
    Chapters:
    +
    26/?
    + + +
    Collections:
    +
    1
    + +
    Comments:
    +
    634
    + +
    Kudos:
    +
    1,524
    + +
    Bookmarks:
    +
    459
    + +
    Hits:
    +
    122,992
    + +
    + + +
  30. +
  31. + + +
    + +

    + Perfect strangers (Connected by fate) + by + + + + + + + + +

    + +
    + Fandoms: + Hazbin Hotel (Cartoon) +   +
    + + + +

    29 Dec 2025

    +
    + + +
    Tags
    + + + +
    Summary
    +
    +

    "-I will go and look for him if I damn well please, so get the FUCK out of my way before I get all of hell to re-enact the french revolution-" He looks different. Alastor recognises him anyway. A part of him thinks he always will, no matter what happens to either of them. Against his wishes, his smile softens.

    +

    "Vox, old pal! Now, now, where are your manners, my dear? This isn't how you should speak to a lady." Vox is popping and sizzling in front of Vaggie as though he is a few heartbeats away from having a seizure. Vaggie is pointing the spear at his screen while the princess stands at her side, trying to calm both of them down (and failing horribly, ah, what a sight!).

    +

    Upon hearing his voice, his old friend throws Vaggie to the side (what a brute) and hurries over to him. His screen glitches out more often than not but Alastor has the honour of watching his eyes widen, his mouth open ever so slightly. Vox is...very blue. And then he is right up in his personal space, snarling and spitting like a vicious animal. All he sees is blue, blue, blue. Alastor smiles indulgently. Some things never change.

    +

    Or: AU where Alastor is part of the Vs

    +
    + +
    Series
    + + + + +
    +
    Language:
    +
    English
    +
    Words:
    +
    18,517
    +
    Chapters:
    +
    5/?
    + + + +
    Comments:
    +
    28
    + +
    Kudos:
    +
    228
    + +
    Bookmarks:
    +
    38
    + +
    Hits:
    +
    2,827
    + +
    + + +
  32. +
  33. + + + + + +
    Tags
    + + + +
    Summary
    +
    +

    If he was spoiled, it was because of rot, not privilege. He was buried under a mountain of bodies — including his own — until he was finally too tired of the tabloids pretending they owned his narrative.

    They wanted a tragedy with good lighting. Fine. Here’s the spotlight. Watch him burn the stage down.

    If he was a joke, he was going to be the scherzo mortale. Choke on the laughter, dollface.

    +
    + + + + +
    +
    Language:
    +
    English
    +
    Words:
    +
    2,520
    +
    Chapters:
    +
    1/1
    + + + + +
    Bookmarks:
    +
    1
    + +
    Hits:
    +
    5
    + +
    + + +
  34. +
  35. + + + + + +
    Tags
    + + + +
    Summary
    +
    +

    Adam is the first man who got banished into the underworld that he created by taing a bite of the apple.
    Only now, after years of suffering, corruption and seeing every kind of misery in Hell, he decided to open up a rehab hotel for sinners who deserve redemption, becomming a better person and get a place in Heaven.Will he succeed in his plan or will he fail to save the few deserving souls?

    Stay tuned ;)

    *******************
    All these characters belong to Vivziepop

    +
    + +
    Series
    + + + + +
    +
    Language:
    +
    English
    +
    Words:
    +
    41,901
    +
    Chapters:
    +
    14/?
    + + + +
    Comments:
    +
    85
    + +
    Kudos:
    +
    100
    + +
    Bookmarks:
    +
    23
    + +
    Hits:
    +
    4,252
    + +
    + + +
  36. +
  37. + + +
    + +

    + Vintage + by + + + + + + + + +

    + +
    + Fandoms: + Hazbin Hotel (Cartoon) +   +
    + + + +

    29 Dec 2025

    +
    + + +
    Tags
    + + + +
    Summary
    +
    +

    Yearn

    +

    To have an intense feeling of longing for something, typically something that one has been separated from or that is unattainable.

    Vox gave everything for Alastor. His soul, his future, his mind and his love. Even after that, did he ever mean anything to him?

    +

    OR

    +

    Vox gave up his soul to Alastor in exchange for working as his right-hand man

    +
    + + + + +
    +
    Language:
    +
    English
    +
    Words:
    +
    4,099
    +
    Chapters:
    +
    2/?
    + + + +
    Comments:
    +
    6
    + +
    Kudos:
    +
    26
    + +
    Bookmarks:
    +
    6
    + +
    Hits:
    +
    207
    + +
    + + +
  38. +
  39. + + +
    + +

    + The Sheep in Deer's Clothing + by + + + + + + + + +

    + +
    + Fandoms: + Hazbin Hotel (Cartoon) +   +
    + + + +

    29 Dec 2025

    +
    + + +
    Tags
    + + + +
    Summary
    +
    +

    “AND WELCOME BACK FOLKS AND YOLKS!

    +

    THIS IS THE RADIO DEMON SPEAKING! AND I’VE GOT SOME FANTASTIC NEWS!

    +

    LOOKING TO GET OUT OF HELL? IN NEED OF SAVING AND SALVATION? WELL, DON’T WE HAVE THE PLAN FOR YOU!”
    _________________________________

    +

    Once an ordinary young lady from the 30s, Miss. Charlie was accidentally sent to Hell. Once arriving with new and powerful powers, she’s trying to make her way through the confusing situation she’s in.

    +

    As the years pass, she meets strange and unique and maybe even familiar faces. Whatever will she do? Let’s hope that she makes the best of her situation.

    +

    _________________________________
    Role Reverse AU

    +
    + + + + +
    +
    Language:
    +
    English
    +
    Words:
    +
    41,304
    +
    Chapters:
    +
    10/?
    + + + +
    Comments:
    +
    58
    + +
    Kudos:
    +
    224
    + +
    Bookmarks:
    +
    63
    + +
    Hits:
    +
    4,053
    + +
    + + +
  40. + +
+ + + + +
+

Filters

+
Filter results: +
+
Submit
+
+
+ +
+
+ +
+ +
+

+ Include +

+ ? +
+
+
+
+ Include Ratings +
+ +
+ Include Warnings +
+ +
+ Include Categories +
+ +
+ Include Fandoms +
+ +
+ Include Characters +
+ +
+ Include Relationships +
+ +
+ Include Additional Tags +
+ + + +
+
+
+

+ Exclude +

+ ? +
+
+
+
+ Exclude Ratings +
+ +
+ Exclude Warnings +
+ +
+ Exclude Categories +
+ +
+ Exclude Fandoms +
+ +
+ Exclude Characters +
+ +
+ Exclude Relationships +
+ +
+ Exclude Additional Tags +
+ + + +
+
+ +
+

More Options

+
+ +
+
+
Crossovers
+ +
Completion Status
+ +
Word Count
+ +
Date Updated
+ + + + + +
+ +
+
+ +
+
+
+
Submit
+
+
+ +

+ Clear Filters +

+ +
+ + + + + + +
+ +
+ + +
+ + + +

Navigation

+ +
+
+ +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/tags.test.ts b/tests/tags.test.ts index 6264d6a0..b0ecb7ad 100644 --- a/tests/tags.test.ts +++ b/tests/tags.test.ts @@ -475,3 +475,135 @@ describe("Tags/sub", () => { expect(tag).toMatchObject({ name: "Eventual Romance", subTags: [] }); }); }); + +describe("Tags/synonyms", () => { + it("should fetch tags with same meaning for canonical tags", async () => { + const tag = await getTag({ tagName: "Charlie Magne | Morningstar" }); + + expect(tag.name).toBe("Charlie Magne | Morningstar"); + expect(tag.canonical).toBe(true); + expect(tag.tagsWithSameMeaning).toMatchInlineSnapshot(` + [ + "(Charlie just mentioned)", + "(mentioned) Charlie Magne | Morningstar", + "(mentions) Charlie Magne", + "2p Charlie (Mentioned briefly)", + "2P Charlie - Character", + "2P Charlie Magne", + "Anti-Charlie", + "baby Charlie Magne - Character", + "Baby Charlie Morningstar - Character", + "Background Charlie - Character", + "Background Charlie Magne | Morningstar", + "Blessed Cat Charlie", + "Brief Charlie Morningstar - Character", + "cahrlie", + "Charlie (briefly mentioned)", + "Charlie (Hazbin Hotel)", + "Charlie (Hazbin Hotel) (mentioned)", + "Charlie (Hazbin Hotel) | mentioned", + "Charlie (Hazbin Hotel)(background)", + "Charlie (Hazbing Hotel)", + "Charlie (Mentioned small cameo)", + "Charlie - Mentioned", + "Charlie Hazbin hotel", + "Charlie Hellspawn", + "charlie is here but not enough to tag really", + "Charlie Killjoy", + "Charlie Magne", + "Charlie Magne (Deceased)", + "Charlie Magne (Hazbin Hotel)", + "Charlie Magne (Mention Only)", + "Charlie Magne (mentioned)", + "Charlie Magne (Referenced)", + "Charlie Magne [mentioned]", + "Charlie Magne Morningstar", + "Charlie magne | morningstar ( mentioned )", + "Charlie Magne | Morningstar (Briefly)", + "Charlie Magne | Morningstar (Hazbin Hotel) (mentioned)", + "Charlie Magne | Morningstar (Mention)", + "Charlie Magne | Morningstar (mentioned)", + "Charlie Magne | Morningstar (Minor)", + "Charlie Magne | Morningstar (referenced)", + "Charlie Magne | Morningstar - mentioned", + "Charlie Magne | Morningstar mentioned", + "Charlie Magne | Morningstar ×", + "Charlie Magne | Morningstar(but only mentioned)", + "Charlie Magne | Morningstar(mentioned)", + "Charlie Magne-Morningstar", + "Charlie Magnet", + "Charlie Magne| Morningstar (Hazbin Hotel)", + "Charlie Mange", + "Charlie Mange | Morningstar", + "Charlie Mange | Morningstar (Hazbin Hotel)", + "Charlie mentioned (Hazbin Hotel)", + "Charlie Mor", + "Charlie Moringstar", + "Charlie Morningstar", + "Charlie morningstar (hasbin hotel)", + "Charlie Morningstar (Hazbin Hotel)", + "Charlie Morningstar (Hazbin Hotel) (mentioned)", + "Charlie Morningstar (mentioned)", + "Charlie Morningstar - Mentioned", + "Charlie Morningstar implied", + "Charlie Morningstar(Hazbin Hotel)", + "Charlie Morningstar/Magne", + "charlie's mentioned near the end", + "Charlie's shadow", + "charlie( hazbin hotel)", + "Charlie(Hazbin Hotel)", + "Charlotte "Charlie" Magne | Morningstar", + "Charlotte "Charlie" Morningstar", + "Charlotte Magne", + "Charlotte Magne (Hazbin Hotel)", + "Charlotte Morningstar", + "Charlotte Morningstar (Hazbin Hotel)", + "Charlotte vasiliou magne (hazbin hotel au)", + "Charolette Magne (Hazbin Hotel)", + "child Charlie - Character", + "Cursed Cat Charlie", + "Dark Charlie Magne | Morningstar - Character", + "Dark!Queen Charlie", + "Demeter Charlie", + "Demon Charlie - Character", + "Demon Charlie Magne - Character", + "Demon Charlie Morningstar", + "Demon!Charlie - Character", + "Evil!Charlie - Character", + "Human Charlie - Character", + "Human Charlie Magne (Hazbin Hotel)", + "human!Charlie - Character", + "kid Charlie (Hazbin Hotel)", + "Mention of Charlie Morningstar (Hazbin Hotel)", + "mentioned Charlie", + "Mentioned Charlie (Hazbin Hotel)", + "mentioned Charlie Magne - Character", + "Mentioned Charlie Magne Morningstar", + "mentioned Charlie Magne | Morningstar - Character", + "Mentioned Charlie mange", + "Mentioned Charlie Morningstar", + "Mentioned Charlie Morningstar (Hazbin hotel) - Character", + "Mentions Charlie Magne | Morningstar", + "mentions of Charlie Magne", + "Mentions of Charlie Magne | Morningstar", + "Mentions of Charlie Morningstar - Character", + "Minor Charlie Magne | Morningstar - Character", + "Princess Charlie (Hazbin Hotel)", + "Princess Charlotte Morningstar - Character", + "Referenced Charlie", + "swap charlie", + "The Savant | Charlie Morningstar", + ] + `); + }); + + it("should return empty array for tags with no synonyms", async () => { + const tag = await getTag({ tagName: "Original Senator Characters" }); + + expect(tag).toMatchObject({ + name: "Original Senator Characters", + tagsWithSameMeaning: [], + }); + }); +}); + diff --git a/types/entities.ts b/types/entities.ts index 8034b4bb..75b7c9e6 100644 --- a/types/entities.ts +++ b/types/entities.ts @@ -28,6 +28,7 @@ export interface Tag { tagName: string; parentSubTag: string | null; }>; + tagsWithSameMeaning: string[]; } export type TagSearchType = From c2f4d9309dc9266de1f3926efc8e38d2858a26af Mon Sep 17 00:00:00 2001 From: Essential Randomness Date: Wed, 14 Jan 2026 04:17:05 -0800 Subject: [PATCH 2/3] test case sensitivity in URLs --- src/urls.ts | 5 +- tests/urls.test.ts | 391 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 tests/urls.test.ts diff --git a/src/urls.ts b/src/urls.ts index fd8e31ab..c15a6408 100644 --- a/src/urls.ts +++ b/src/urls.ts @@ -206,7 +206,10 @@ const getSearchParamsFromTagFilters = ( const parameters = { name: searchFilters.tagName ?? "", fandoms: searchFilters.fandoms?.join(",") ?? "", - type: searchFilters.type?.toLowerCase() ?? "", + type: searchFilters.type + ? searchFilters.type.charAt(0).toUpperCase() + + searchFilters.type.slice(1).toLowerCase() + : "", wrangling_status: searchFilters.wranglingStatus // We remove the _or_ and _and_ that we added for readability diff --git a/tests/urls.test.ts b/tests/urls.test.ts new file mode 100644 index 00000000..77ac80b2 --- /dev/null +++ b/tests/urls.test.ts @@ -0,0 +1,391 @@ +import { describe, it, expect } from "vitest"; +import { + getSearchUrlFromTagFilters, + getTagUrl, + getTagWorksFeedUrl, +} from "src/urls"; + +/** + * Verify generated URL strings to catch case-sensitivity issues. The end-to-end mock + * tests aren't sufficient by themselves because: + * + * 1. Some file systems (like MacOS') are case-insensitive, which means that + * paths like "Character" and "character" resolve to the same file. + * 2. AO3's API requires specific casing for URL parameters (e.g., `type=Character`) + * 3. Our end-to-end tests use file-based mocks, which can't reliably catch case-sensitivity + * bugs because of #1. + * + */ +describe("getSearchUrlFromTagFilters", () => { + describe("type parameter casing", () => { + it("should title-case the type parameter for 'character'", () => { + const url = new URL( + getSearchUrlFromTagFilters({ + type: "character", + tagName: null, + fandoms: [], + wranglingStatus: "any", + sortColumn: "name", + sortDirection: "asc", + page: 1, + }), + ); + + expect(Object.fromEntries(url.searchParams)).toEqual({ + page: "1", + commit: "Search Tags", + "tag_search[name]": "", + "tag_search[fandoms]": "", + "tag_search[type]": "Character", + "tag_search[wrangling_status]": "any", + "tag_search[sort_column]": "name", + "tag_search[sort_direction]": "asc", + }); + }); + + it("should title-case the type parameter for 'fandom'", () => { + const url = new URL( + getSearchUrlFromTagFilters({ + type: "fandom", + tagName: null, + fandoms: [], + wranglingStatus: "any", + sortColumn: "name", + sortDirection: "asc", + page: 1, + }), + ); + + expect(Object.fromEntries(url.searchParams)).toEqual({ + page: "1", + commit: "Search Tags", + "tag_search[name]": "", + "tag_search[fandoms]": "", + "tag_search[type]": "Fandom", + "tag_search[wrangling_status]": "any", + "tag_search[sort_column]": "name", + "tag_search[sort_direction]": "asc", + }); + }); + + it("should title-case the type parameter for 'relationship'", () => { + const url = new URL( + getSearchUrlFromTagFilters({ + type: "relationship", + tagName: null, + fandoms: [], + wranglingStatus: "any", + sortColumn: "name", + sortDirection: "asc", + page: 1, + }), + ); + + expect(Object.fromEntries(url.searchParams)).toEqual({ + page: "1", + commit: "Search Tags", + "tag_search[name]": "", + "tag_search[fandoms]": "", + "tag_search[type]": "Relationship", + "tag_search[wrangling_status]": "any", + "tag_search[sort_column]": "name", + "tag_search[sort_direction]": "asc", + }); + }); + + it("should title-case the type parameter for 'freeform'", () => { + const url = new URL( + getSearchUrlFromTagFilters({ + type: "freeform", + tagName: null, + fandoms: [], + wranglingStatus: "any", + sortColumn: "name", + sortDirection: "asc", + page: 1, + }), + ); + + expect(Object.fromEntries(url.searchParams)).toEqual({ + page: "1", + commit: "Search Tags", + "tag_search[name]": "", + "tag_search[fandoms]": "", + "tag_search[type]": "Freeform", + "tag_search[wrangling_status]": "any", + "tag_search[sort_column]": "name", + "tag_search[sort_direction]": "asc", + }); + }); + + it("should title-case 'any' type", () => { + const url = new URL( + getSearchUrlFromTagFilters({ + type: "any", + tagName: null, + fandoms: [], + wranglingStatus: "any", + sortColumn: "name", + sortDirection: "asc", + page: 1, + }), + ); + + expect(Object.fromEntries(url.searchParams)).toEqual({ + page: "1", + commit: "Search Tags", + "tag_search[name]": "", + "tag_search[fandoms]": "", + "tag_search[type]": "Any", + "tag_search[wrangling_status]": "any", + "tag_search[sort_column]": "name", + "tag_search[sort_direction]": "asc", + }); + }); + }); + + describe("other parameters", () => { + it("should include page parameter", () => { + const url = new URL( + getSearchUrlFromTagFilters({ + type: "character", + tagName: null, + fandoms: [], + wranglingStatus: "any", + sortColumn: "name", + sortDirection: "asc", + page: 3, + }), + ); + + expect(Object.fromEntries(url.searchParams)).toEqual({ + page: "3", + commit: "Search Tags", + "tag_search[name]": "", + "tag_search[fandoms]": "", + "tag_search[type]": "Character", + "tag_search[wrangling_status]": "any", + "tag_search[sort_column]": "name", + "tag_search[sort_direction]": "asc", + }); + }); + + it("should include tagName when provided", () => { + const url = new URL( + getSearchUrlFromTagFilters({ + type: "character", + tagName: "test tag", + fandoms: [], + wranglingStatus: "any", + sortColumn: "name", + sortDirection: "asc", + page: 1, + }), + ); + + expect(Object.fromEntries(url.searchParams)).toEqual({ + page: "1", + commit: "Search Tags", + "tag_search[name]": "test tag", + "tag_search[fandoms]": "", + "tag_search[type]": "Character", + "tag_search[wrangling_status]": "any", + "tag_search[sort_column]": "name", + "tag_search[sort_direction]": "asc", + }); + }); + + it("should include fandoms when provided", () => { + const url = new URL( + getSearchUrlFromTagFilters({ + type: "character", + tagName: null, + fandoms: ["Hazbin Hotel (Cartoon)"], + wranglingStatus: "any", + sortColumn: "name", + sortDirection: "asc", + page: 1, + }), + ); + + expect(Object.fromEntries(url.searchParams)).toEqual({ + page: "1", + commit: "Search Tags", + "tag_search[name]": "", + "tag_search[fandoms]": "Hazbin Hotel (Cartoon)", + "tag_search[type]": "Character", + "tag_search[wrangling_status]": "any", + "tag_search[sort_column]": "name", + "tag_search[sort_direction]": "asc", + }); + }); + + it("should handle wranglingStatus 'canonical' correctly", () => { + const url = new URL( + getSearchUrlFromTagFilters({ + type: "character", + tagName: null, + fandoms: [], + wranglingStatus: "canonical", + sortColumn: "name", + sortDirection: "asc", + page: 1, + }), + ); + + expect(Object.fromEntries(url.searchParams)).toEqual({ + page: "1", + commit: "Search Tags", + "tag_search[name]": "", + "tag_search[fandoms]": "", + "tag_search[type]": "Character", + "tag_search[wrangling_status]": "canonical", + "tag_search[sort_column]": "name", + "tag_search[sort_direction]": "asc", + }); + }); + + it("should convert 'canonical_or_synonymous' to 'canonical_synonymous'", () => { + const url = new URL( + getSearchUrlFromTagFilters({ + type: "character", + tagName: null, + fandoms: [], + wranglingStatus: "canonical_or_synonymous", + sortColumn: "name", + sortDirection: "asc", + page: 1, + }), + ); + + expect(Object.fromEntries(url.searchParams)).toEqual({ + page: "1", + commit: "Search Tags", + "tag_search[name]": "", + "tag_search[fandoms]": "", + "tag_search[type]": "Character", + "tag_search[wrangling_status]": "canonical_synonymous", + "tag_search[sort_column]": "name", + "tag_search[sort_direction]": "asc", + }); + }); + + it("should convert 'noncanonical_and_nonsynonymous' to 'noncanonical_nonsynonymous'", () => { + const url = new URL( + getSearchUrlFromTagFilters({ + type: "character", + tagName: null, + fandoms: [], + wranglingStatus: "noncanonical_and_nonsynonymous", + sortColumn: "name", + sortDirection: "asc", + page: 1, + }), + ); + + expect(Object.fromEntries(url.searchParams)).toEqual({ + page: "1", + commit: "Search Tags", + "tag_search[name]": "", + "tag_search[fandoms]": "", + "tag_search[type]": "Character", + "tag_search[wrangling_status]": "noncanonical_nonsynonymous", + "tag_search[sort_column]": "name", + "tag_search[sort_direction]": "asc", + }); + }); + + it("should convert works_count sortColumn to 'uses'", () => { + const url = new URL( + getSearchUrlFromTagFilters({ + type: "character", + tagName: null, + fandoms: [], + wranglingStatus: "any", + sortColumn: "works_count", + sortDirection: "desc", + page: 1, + }), + ); + + expect(Object.fromEntries(url.searchParams)).toEqual({ + page: "1", + commit: "Search Tags", + "tag_search[name]": "", + "tag_search[fandoms]": "", + "tag_search[type]": "Character", + "tag_search[wrangling_status]": "any", + "tag_search[sort_column]": "uses", + "tag_search[sort_direction]": "desc", + }); + }); + }); +}); + +/** + * Verify getTagUrl special character replacements. + * + * AO3 uses a custom encoding for certain characters in tag URLs that differs + * from standard URL encoding. The redownload-articles.mts script handles these + * same replacements when reconstructing URLs from file paths, so we test them + * here to make sure we don't accidentally break them in the library. + */ +describe("getTagUrl", () => { + describe("special character replacements", () => { + it("should replace forward slash with *s*", () => { + const url = new URL(getTagUrl("M/M")); + + expect(url.pathname).toBe("/tags/M*s*M/"); + }); + + it("should replace ampersand with *a*", () => { + const url = new URL(getTagUrl("Tom & Jerry")); + + expect(url.pathname).toBe("/tags/Tom%20*a*%20Jerry/"); + }); + + it("should replace period with *d*", () => { + const url = new URL(getTagUrl("Dr. Who")); + + expect(url.pathname).toBe("/tags/Dr*d*%20Who/"); + }); + + it("should replace hash with *h*", () => { + const url = new URL(getTagUrl("C#")); + + expect(url.pathname).toBe("/tags/C*h*/"); + }); + + it("should replace question mark with *q*", () => { + const url = new URL(getTagUrl("What If?")); + + expect(url.pathname).toBe("/tags/What%20If*q*/"); + }); + + it("should handle multiple special characters", () => { + const url = new URL(getTagUrl("A/B & C.D")); + + expect(url.pathname).toBe("/tags/A*s*B%20*a*%20C*d*D/"); + }); + + it("should end with trailing slash", () => { + const url = new URL(getTagUrl("Simple Tag")); + + expect(url.pathname).toBe("/tags/Simple%20Tag/"); + }); + }); +}); + +describe("getTagWorksFeedUrl", () => { + it("should append /works to tag URL", () => { + const url = new URL(getTagWorksFeedUrl("M/M")); + + expect(url.pathname).toBe("/tags/M*s*M/works"); + }); + + it("should handle special characters in tag name", () => { + const url = new URL(getTagWorksFeedUrl("Tom & Jerry")); + + expect(url.pathname).toBe("/tags/Tom%20*a*%20Jerry/works"); + }); +}); From fd944e5356767a69b1b394786edede498ec0dad7 Mon Sep 17 00:00:00 2001 From: Essential Randomness Date: Sat, 24 Jan 2026 20:38:29 -0800 Subject: [PATCH 3/3] Fix any type in search --- src/urls.ts | 31 ++++++++++--------- .../03.html | 8 ++--- tests/tag-search.test.ts | 3 +- tests/urls.test.ts | 2 +- 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/urls.ts b/src/urls.ts index c15a6408..f56c21da 100644 --- a/src/urls.ts +++ b/src/urls.ts @@ -79,7 +79,7 @@ export const getAsShortUrl = ({ url }: { url: string | URL }) => { const longUrl = new URL(url); if (longUrl.hostname !== "archiveofourown.org") { throw new Error( - `Short URLs are only supported for AO3 (found: ${longUrl.hostname})` + `Short URLs are only supported for AO3 (found: ${longUrl.hostname})`, ); } @@ -99,8 +99,8 @@ export const getDownloadUrls = ({ updatedAt, publishedAt, }: // Make it so you can either pass specifically the needed elements of a work, -// but also the whole summary if you prefer -| Pick + // but also the whole summary if you prefer + | Pick | WorkSummary) => { const timestamp = new Date(updatedAt ?? publishedAt).valueOf(); const downloadLinkBase = new URL(`downloads/${id}/`, getArchiveBaseUrl()) @@ -130,7 +130,7 @@ const TOKEN_REPLACEMENTS_MAP = { type ReplaceableToken = keyof typeof TOKEN_REPLACEMENTS_MAP; const REPLACEABLE_TOKENS = Object.keys( - TOKEN_REPLACEMENTS_MAP + TOKEN_REPLACEMENTS_MAP, ) as ReplaceableToken[]; const TOKENS_TO_ESCAPE = ["/", "?", "."]; @@ -145,9 +145,9 @@ const isReplaceableToken = (c: string): c is ReplaceableToken => */ const REPLACE_TOKENS_REGEX = new RegExp( `(${REPLACEABLE_TOKENS.map((token) => - shouldEscapeToken(token) ? `\\${token}` : token + shouldEscapeToken(token) ? `\\${token}` : token, ).join("|")})`, - "g" + "g", ); export const getTagUrl = (tagName: string) => @@ -155,9 +155,9 @@ export const getTagUrl = (tagName: string) => `tags/${encodeURI(tagName).replaceAll( REPLACE_TOKENS_REGEX, (char: string) => - isReplaceableToken(char) ? TOKEN_REPLACEMENTS_MAP[char] : char + isReplaceableToken(char) ? TOKEN_REPLACEMENTS_MAP[char] : char, )}/`, - getArchiveBaseUrl() + getArchiveBaseUrl(), ).href; export const getTagWorksFeedUrl = (tagName: string) => @@ -198,7 +198,7 @@ export const getWorkDetailsFromUrl = ({ }; const getSearchParamsFromTagFilters = ( - searchFilters: Partial + searchFilters: Partial, ) => { // Prepare the parameters for the search as a map first. This makes them a bit // more readable, since these parameters will all need to be wrapped with with @@ -206,10 +206,13 @@ const getSearchParamsFromTagFilters = ( const parameters = { name: searchFilters.tagName ?? "", fandoms: searchFilters.fandoms?.join(",") ?? "", - type: searchFilters.type - ? searchFilters.type.charAt(0).toUpperCase() + - searchFilters.type.slice(1).toLowerCase() - : "", + // AO3 requires an empty string for "any" type + // This is not the same for wrangling_status, somehow + type: + searchFilters.type && searchFilters.type !== "any" + ? searchFilters.type.charAt(0).toUpperCase() + + searchFilters.type.slice(1).toLowerCase() + : "", wrangling_status: searchFilters.wranglingStatus // We remove the _or_ and _and_ that we added for readability @@ -219,7 +222,7 @@ const getSearchParamsFromTagFilters = ( sort_column: searchFilters.sortColumn === "works_count" ? "uses" - : searchFilters.sortColumn ?? "name", + : (searchFilters.sortColumn ?? "name"), sort_direction: searchFilters.sortDirection ?? "asc", }; diff --git a/tests/mocks/data/ao3/tag-search/commit=search tags__tag_search[fandoms]=__tag_search[name]=an unusual__tag_search[sort_column]=name__tag_search[sort_direction]=asc__tag_search[type]=__tag_search[wrangling_status]=/03.html b/tests/mocks/data/ao3/tag-search/commit=search tags__tag_search[fandoms]=__tag_search[name]=an unusual__tag_search[sort_column]=name__tag_search[sort_direction]=asc__tag_search[type]=__tag_search[wrangling_status]=/03.html index ec4ca95e..bbf2a11e 100644 --- a/tests/mocks/data/ao3/tag-search/commit=search tags__tag_search[fandoms]=__tag_search[name]=an unusual__tag_search[sort_column]=name__tag_search[sort_direction]=asc__tag_search[type]=__tag_search[wrangling_status]=/03.html +++ b/tests/mocks/data/ao3/tag-search/commit=search tags__tag_search[fandoms]=__tag_search[name]=an unusual__tag_search[sort_column]=name__tag_search[sort_direction]=asc__tag_search[type]=__tag_search[wrangling_status]=/03.html @@ -34,7 +34,7 @@ - + @@ -57,7 +57,7 @@

Log In