inital commit
This commit is contained in:
674
LICENSE
Normal file
674
LICENSE
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
142
README.md
Normal file
142
README.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# go-fa-api
|
||||||
|
|
||||||
|
Go 1.25+ SDK for [FurAffinity](https://www.furaffinity.net). FA has no JSON
|
||||||
|
API, so this library scrapes the beta theme via [Colly](https://github.com/gocolly/colly)
|
||||||
|
internally and exposes a strongly typed surface.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| Area | What you get |
|
||||||
|
|------|--------------|
|
||||||
|
| **Read** | `GetSubmission` · `GetUser` · `GetJournal` · `Download` |
|
||||||
|
| **Iterators** (`iter.Seq2`) | `Gallery` · `Scraps` · `Favorites` · `UserJournals` · `SubmissionComments` · `JournalComments` |
|
||||||
|
| **Discovery** | `Browse(BrowseOptions)` · `Search(query, SearchOptions)` full filter struct (rating/type/order/range/etc.) |
|
||||||
|
| **Inbox** | `SubmissionInbox` (watched-users feed) · `Notes` · `GetNote` · `Notifications` (journals/watches/comments/favs/shouts) |
|
||||||
|
| **Write** | `Fav`/`Unfav` · `Watch`/`Unwatch` · `PostSubmissionComment` · `PostJournalComment` · `SendNote` |
|
||||||
|
| **Auth** | `WithCookies(a, b)` · `WithCloudflare(cf_clearance)` · `WithUserAgent` · `WithSFW(SFWOn/Off/Auto)` |
|
||||||
|
| **Networking** | Built-in 1 req/s rate limiter · Cloudflare challenge detection · 429 `Retry-After` · 5xx exponential backoff · `context.Context` end-to-end |
|
||||||
|
| **Experimental** | `WithExperimentalJSONListings(true)` listing pages merge from FA's embedded `js-submissionData` JSON first, HTML fallback if it's absent. More resilient to markup drift; off by default. |
|
||||||
|
|
||||||
|
Pre-1.0: expect breaking changes. Beta theme only; classic theme is not parsed.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get git.anthrove.art/public/go-fa-api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
fa "git.anthrove.art/public/go-fa-api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
client := fa.New(
|
||||||
|
fa.WithCookies(fa.Cookies{A: os.Getenv("FA_A"), B: os.Getenv("FA_B")}),
|
||||||
|
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
|
||||||
|
fa.WithUserAgent(os.Getenv("FA_UA")),
|
||||||
|
)
|
||||||
|
|
||||||
|
sub, err := client.GetSubmission(context.Background(), 12345678)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("%s by %s\n", sub.Title, sub.Author.DisplayName)
|
||||||
|
|
||||||
|
for sub, err := range client.Gallery(context.Background(), "someuser", fa.ListOptions{MaxPages: 3}) {
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println(sub.ID, sub.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Runnable examples in `examples/`: `basic`, `gallery_dump`, `download`, `browse`, `search`, `inbox`, `notes`, `notifications`.
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
FA's auth is cookie-based; Cloudflare gates submissions behind a clearance
|
||||||
|
cookie bound to your browser's User-Agent. The SDK doesn't log in for you copy the values once:
|
||||||
|
|
||||||
|
1. Log in at https://www.furaffinity.net in your browser.
|
||||||
|
2. DevTools → **Application** → **Cookies** → copy `a`, `b`, and `cf_clearance`.
|
||||||
|
3. DevTools → **Network** → any FA request → copy the **User-Agent** header
|
||||||
|
*exactly*. CF binds `cf_clearance` to that UA; mismatch = challenge.
|
||||||
|
4. Pass them in via `fa.WithCookies` / `fa.WithCloudflare` / `fa.WithUserAgent`.
|
||||||
|
|
||||||
|
If FA challenges you later, your `cf_clearance` expired refresh it and the
|
||||||
|
SDK will return `fa.ErrCloudflareChallenge` so you can detect it.
|
||||||
|
|
||||||
|
> Account preference must be set to the **beta** theme (Site Preferences → Style → Beta).
|
||||||
|
|
||||||
|
## Rate limiting
|
||||||
|
|
||||||
|
Every request (page fetches *and* downloads) passes through one token bucket.
|
||||||
|
Default is **1 req/s**, lowest safe for FA. Lives inside the
|
||||||
|
`http.RoundTripper` so callers can't bypass it.
|
||||||
|
|
||||||
|
```go
|
||||||
|
fa.New(fa.WithRateLimit(2*time.Second, 1)) // explicit
|
||||||
|
fa.New(fa.WithRequestsPerSecond(0.5)) // shorthand
|
||||||
|
```
|
||||||
|
|
||||||
|
## Iterators
|
||||||
|
|
||||||
|
Paginated endpoints return `iter.Seq2[*T, error]`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
for sub, err := range client.Gallery(ctx, "someuser", fa.ListOptions{}) {
|
||||||
|
if err != nil { return err }
|
||||||
|
fmt.Println(sub.ID, sub.Title)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Lazy** pages fetched on demand; `break` stops further fetches.
|
||||||
|
- **Terminal errors** first error yields `(nil, err)` and stops the iterator.
|
||||||
|
- **One option-shape per iterator class**: simple paginated iterators
|
||||||
|
(Gallery / Scraps / Favorites / UserJournals / SubmissionInbox / Notes)
|
||||||
|
take `fa.ListOptions{StartPage, MaxPages}`; filtered iterators
|
||||||
|
(`Search`, `Browse`) take their own `SearchOptions` / `BrowseOptions`
|
||||||
|
struct that folds the same pagination fields in.
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
Test with `errors.Is`:
|
||||||
|
|
||||||
|
| Sentinel | When | What to do |
|
||||||
|
|----------|------|-----------|
|
||||||
|
| `ErrNotFound` | FA "not found" / deleted submission | surface to user |
|
||||||
|
| `ErrUnauthorized` | endpoint needs login or `Login Required` page | refresh `a`/`b` |
|
||||||
|
| `ErrCloudflareChallenge` | CF interposed a challenge | refresh `cf_clearance` |
|
||||||
|
| `ErrRateLimited` | 429 after retry budget exhausted | slow down |
|
||||||
|
| `ErrSystemMessage` | uncategorised FA system message | inspect `*SystemMessageError` |
|
||||||
|
| `ErrParse` | HTML didn't match selectors | likely FA shifted markup open an issue |
|
||||||
|
|
||||||
|
## Test fixtures
|
||||||
|
|
||||||
|
Parsers are selector-driven; FA's HTML drifts. `TestRefreshFixtures` (build
|
||||||
|
tag `fixtures`) hits live FA with your cookies and snapshots each curated
|
||||||
|
page into `testdata/html/`. Parser `*_RealFixture` tests `t.Skip` cleanly
|
||||||
|
when their file is absent, so the basic suite works without any cookies.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Configure once (or edit scripts/test.sh):
|
||||||
|
export FA_A=... FA_B=... CF_CLEARANCE=... FA_UA='Mozilla/5.0 ...'
|
||||||
|
export FA_TEST_USER=yourlogin FA_TEST_SUB_ID=... FA_TEST_JOURNAL_ID=...
|
||||||
|
# Optional refinements: FA_TEST_SUB_STORY_ID, FA_TEST_USER_WITH_SHOUTS,
|
||||||
|
# FA_TEST_GALLERY_USER, FA_TEST_GALLERY_LAST_PAGE, FA_TEST_NOTE_ID,
|
||||||
|
# FA_TEST_SEARCH_QUERY each gates a separate fixture; unset = skip.
|
||||||
|
|
||||||
|
go test -tags=fixtures -run TestRefreshFixtures -v ./... # or ./scripts/test.sh
|
||||||
|
go test ./... # now *_RealFixture tests activate
|
||||||
|
```
|
||||||
268
SDK_ISSUES.md
Normal file
268
SDK_ISSUES.md
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
# SDK issues FA Droid
|
||||||
|
|
||||||
|
Bugs whose root cause is in the **FA SDK** (`go-fa-api`, at
|
||||||
|
`/var/home/soxx/git/go-fa-api`, `replace`d in `go.mod`) not in this app.
|
||||||
|
|
||||||
|
The SDK is a **separate module, maintained and fixed by a separate AI**
|
||||||
|
that does not see this app's code or conversation context. This file is the
|
||||||
|
handoff: every entry is a self-contained bug report meant to be copy-pasted
|
||||||
|
to that AI as-is.
|
||||||
|
|
||||||
|
App-side bugs (frontend / go-service) live in `ISSUES.md`; fixed issues in
|
||||||
|
`SOLVED.md`. Features with UI but no backend are in `STUBS.md`; Wails/Android
|
||||||
|
stack traps in `PITFALLS.md`.
|
||||||
|
|
||||||
|
**Status legend:** `[ ]` open · `[~]` diagnosed handoff brief drafted ·
|
||||||
|
`[x]` fixed in the SDK (then moved to `SOLVED.md`)
|
||||||
|
**Numbering:** issue numbers are shared across `ISSUES.md`, `SOLVED.md` and
|
||||||
|
this file draw the next free number from the same sequence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SDK handoff brief required fields
|
||||||
|
|
||||||
|
Every entry MUST carry a handoff brief detailed enough to be copy-pasted to
|
||||||
|
the SDK AI as a standalone bug report. Do not write "sdk issue?" and stop —
|
||||||
|
that is not actionable for someone without this repo open.
|
||||||
|
|
||||||
|
A handoff brief must answer all seven of:
|
||||||
|
|
||||||
|
1. **SDK entry point** the exact exported function involved
|
||||||
|
(e.g. `Client.Fav(ctx, SubmissionID)` in `actions.go`). Name the file.
|
||||||
|
2. **How the app calls it** which `internal/services/*.go` wrapper
|
||||||
|
invokes it, and with what arguments.
|
||||||
|
3. **Observed behaviour** what the SDK call returns: an error (quote it
|
||||||
|
verbatim), a wrong value, or a silent no-op.
|
||||||
|
4. **Expected behaviour** what FurAffinity should do server-side and
|
||||||
|
what the SDK should return.
|
||||||
|
5. **Reproduction** concrete inputs (submission ID, username) and the
|
||||||
|
steps that trigger it.
|
||||||
|
6. **Suspected layer** HTML parsing (FA changed its markup?), request
|
||||||
|
shape (wrong form fields / missing CSRF / wrong URL), auth/cookies, or
|
||||||
|
rate-limiting. Point at the likely file: parsers are `*_parser.go`,
|
||||||
|
form posts are in `actions.go` / `*_post.go` / `*_send.go`.
|
||||||
|
7. **What is NOT the SDK** explicitly rule out the app layer so the SDK
|
||||||
|
AI does not chase a frontend bug. State what you verified.
|
||||||
|
|
||||||
|
If you cannot yet fill all seven fields, the issue stays `[~]` and the brief
|
||||||
|
lists exactly which diagnostics are still needed never hand over a
|
||||||
|
half-brief as if it were complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issues
|
||||||
|
|
||||||
|
### #4 [~] Audit the FA SDK for bugs
|
||||||
|
- **Where:** `go-fa-api` (sibling module, `replace`d in `go.mod`).
|
||||||
|
- **Task:** Standing umbrella review the SDK surface used by
|
||||||
|
`internal/services/` for incorrect parsing, missing endpoints, or wrong
|
||||||
|
request shapes.
|
||||||
|
- **Cause tag:** `sdk`
|
||||||
|
- **Progress:** The first audit pass produced #1, #5, #9, #10 and #14 **all
|
||||||
|
now fixed and verified** (#1/#5/#9/#10 in app v0.0.7, #14 in v0.0.10; see
|
||||||
|
`SOLVED.md`). #15 was then fixed too (SDK gave `WithResolvedAvatars` a
|
||||||
|
count limit; app v0.0.14). #17 (priority rate limiting) was implemented too
|
||||||
|
— verified in app v0.0.16. **No open SDK issues currently.** Keep this
|
||||||
|
entry as the umbrella for future audit passes; file each concrete finding
|
||||||
|
as its own numbered issue.
|
||||||
|
|
||||||
|
_No open SDK issues. Fixed ones are recorded in `SOLVED.md`._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Add new SDK issues below
|
||||||
|
|
||||||
|
<!-- #N title (N = next free number, shared with ISSUES.md)
|
||||||
|
- Where:
|
||||||
|
- Symptom:
|
||||||
|
- Expected:
|
||||||
|
- Cause tag: sdk
|
||||||
|
- SDK handoff brief:
|
||||||
|
1. Entry point:
|
||||||
|
2. How the app calls it:
|
||||||
|
3. Observed behaviour:
|
||||||
|
4. Expected behaviour:
|
||||||
|
5. Reproduction:
|
||||||
|
6. Suspected layer:
|
||||||
|
7. What is NOT the SDK:
|
||||||
|
-->
|
||||||
|
|
||||||
|
### #18 [x] Rate limiter: upgrade from 2-level to N-level priority
|
||||||
|
|
||||||
|
**Implemented in the SDK update** `options.go` now exposes `WithPriority`
|
||||||
|
and a multi-level `Priority` type; `WithPrioritizedRateLimiting` honours it.
|
||||||
|
FA Droid follow-up (assign tiers to reads/writes/preload/crawl) is tracked as
|
||||||
|
app-side work, not an SDK issue.
|
||||||
|
|
||||||
|
|
||||||
|
- **Component:** `go-fa-api` `ratelimit.go`, `options.go`.
|
||||||
|
- **Today:** the limiter has exactly two priorities. `WithBackgroundPriority(ctx)`
|
||||||
|
marks a request background; everything else is foreground. Background
|
||||||
|
requests wait until no foreground request is queued. Enabled via
|
||||||
|
`WithPrioritizedRateLimiting(true)`.
|
||||||
|
- **Need:** a consumer (FA Droid) wants *three+* tiers, not two:
|
||||||
|
1. user-interactive (the page on screen, user write actions),
|
||||||
|
2. speculative neighbor preload (likely-next submissions),
|
||||||
|
3. bulk background crawl (inbox/watchlist warming).
|
||||||
|
With only two levels, neighbor preload must either compete with the live
|
||||||
|
page (tier 1) or interleave with the bulk crawl (tier 2) neither is right.
|
||||||
|
- **Proposed API:** keep `WithBackgroundPriority` working (maps to the lowest
|
||||||
|
level) and add `WithPriority(ctx, Priority)` where `Priority` is an ordered
|
||||||
|
enum, e.g. `PriorityInteractive`, `PriorityNormal` (default),
|
||||||
|
`PriorityLow`, `PriorityBackground`. The limiter serves waiting goroutines
|
||||||
|
highest-priority-first; the token emission rate is unchanged.
|
||||||
|
- **Compatibility:** default (no marker) must remain `PriorityNormal`;
|
||||||
|
`WithBackgroundPriority` must remain equivalent to `PriorityBackground`.
|
||||||
|
- **Why it matters to the app:** FA Droid will then assign tier 1 to
|
||||||
|
current-page reads + the write-action queue worker, tier `PriorityLow` to
|
||||||
|
neighbor preload, and `PriorityBackground` to the inbox crawler.
|
||||||
|
|
||||||
|
### #21 [x] GetSubmission doesn't expose the viewer's favorite state
|
||||||
|
|
||||||
|
- **Where:** `go-fa-api` `submission.go` (`Submission` struct + `parseSubmission`).
|
||||||
|
- **Symptom:** A submission the logged-in user has favorited shows the
|
||||||
|
favorite heart as *empty* on the app's submission detail page. There is no
|
||||||
|
way to know a submission's favorite state from a `GetSubmission` result.
|
||||||
|
- **Expected:** `GetSubmission` should report whether the authenticated viewer
|
||||||
|
has favorited the submission, so the UI can render the correct heart state.
|
||||||
|
- **Cause tag:** `sdk`
|
||||||
|
- **SDK handoff brief:**
|
||||||
|
1. **Entry point:** `Client.GetSubmission(ctx, SubmissionID)` in
|
||||||
|
`submission.go`, which builds the result via `parseSubmission(id, doc)`.
|
||||||
|
The returned `Submission` struct (`submission.go:17-38`) has fields
|
||||||
|
ID, Title, Author, PostedAt, Rating, Category, Type, Species, Gender,
|
||||||
|
Description, DescriptionText, Tags, FileURL, ThumbURL, Width, Height,
|
||||||
|
Stats, Folders, Prev, Next and **nothing indicating favorite state**.
|
||||||
|
2. **How the app calls it:** `SubmissionService.getCached` in
|
||||||
|
`internal/services/submission.go` calls `GetSubmission`, then
|
||||||
|
`dto.FromSubmission` (`internal/dto/types.go`) copies the struct to the
|
||||||
|
wire DTO. The frontend (`SubmissionView.svelte`) does
|
||||||
|
`favorited = !!sub.favorited`.
|
||||||
|
3. **Observed behaviour:** `GetSubmission` returns a `*Submission` with no
|
||||||
|
favorite-state field. It is not a wrong value or an error the datum is
|
||||||
|
simply absent from the SDK's public type.
|
||||||
|
4. **Expected behaviour:** When `/view/{id}/` is fetched with valid `a`/`b`
|
||||||
|
cookies, FA renders either a `+Fav` (`/fav/{id}/...`) or a `−Fav`
|
||||||
|
(`/unfav/{id}/...`) anchor exactly one, matching the viewer's current
|
||||||
|
state. The SDK should surface this on `Submission`, e.g. a new
|
||||||
|
`Favorited bool` field (final name your call), set true when the page
|
||||||
|
shows the `/unfav/` link. On an anonymous (no-cookie) fetch neither link
|
||||||
|
is present → `Favorited` false, which is correct.
|
||||||
|
5. **Reproduction:** With cookies set, favorite submission X on FA, then
|
||||||
|
call `GetSubmission(ctx, X)` the result cannot express that X is
|
||||||
|
favorited.
|
||||||
|
6. **Suspected layer:** HTML parsing `parseSubmission`. The SDK *already*
|
||||||
|
has the exact parser: `findFavLinks(doc, subID) (favURL, unfavURL string)`
|
||||||
|
in `actions.go` (used by `toggleFavorite` for its idempotency check).
|
||||||
|
`unfavURL != ""` means "currently favorited." `parseSubmission` just needs
|
||||||
|
to run that check and set the new field. No new scraping logic required.
|
||||||
|
7. **What is NOT the SDK:** The app side is verified ready and correct.
|
||||||
|
`SubmissionView.svelte` already reads `sub.favorited` and types it
|
||||||
|
(`favorited?: boolean`); `getCached` correctly busts and re-fetches the
|
||||||
|
submission cache after a fav write (via `WriteService` / the action
|
||||||
|
queue). The value never reaches the app only because the SDK `Submission`
|
||||||
|
struct has no field to carry it `dto.FromSubmission` has nothing to map.
|
||||||
|
- **Related (please also check):** the same gap likely exists for *watch*
|
||||||
|
state `findWatchLinks` exists in `actions.go`, but the `User` struct may
|
||||||
|
not expose whether the viewer watches that user. Worth fixing in the same
|
||||||
|
pass.
|
||||||
|
- **When it lands (FA Droid follow-up):** add `Favorited bool` to
|
||||||
|
`dto.Submission` (`json:"favorited"`), map it in `dto.FromSubmission`, and
|
||||||
|
regenerate bindings. The frontend already consumes `sub.favorited`, so no UI
|
||||||
|
change is needed.
|
||||||
|
- **Status:** done SDK update added `Submission.Favorited`; app wired it in
|
||||||
|
v0.0.22.
|
||||||
|
|
||||||
|
### #23 [ ] SubmissionInbox yields only the first page (~72 items)
|
||||||
|
|
||||||
|
- **Where:** `go-fa-api` `inbox.go` (`SubmissionInbox` + `parseSubmissionInboxPage`).
|
||||||
|
- **Symptom:** The new-submission inbox shows only ~72 items even when the
|
||||||
|
account has thousands pending. A user with ~4718 inbox submissions sees one
|
||||||
|
page.
|
||||||
|
- **Expected:** `SubmissionInbox` should walk every cursor page until FA stops
|
||||||
|
rendering a "Next 72" link.
|
||||||
|
- **Cause tag:** `sdk`
|
||||||
|
- **SDK handoff brief:**
|
||||||
|
1. **Entry point:** `Client.SubmissionInbox(ctx, ListOptions)` in `inbox.go`,
|
||||||
|
whose iterator follows `parseSubmissionInboxPage`'s returned `nextURL`.
|
||||||
|
2. **How the app calls it:** `InboxService.StreamSubmissions`
|
||||||
|
(`internal/services/inbox.go`) runs one goroutine that does
|
||||||
|
`for sub, err := range client.SubmissionInbox(ctx, fa.ListOptions{})`
|
||||||
|
— `ListOptions{}` means `MaxPages: 0` (unbounded), so the app does not
|
||||||
|
cap the crawl.
|
||||||
|
3. **Observed behaviour:** the `range` completes after ~72 items the
|
||||||
|
iterator yields one page then ends. Verified on-device: the app's crawl
|
||||||
|
channel (fed one item per `yield`) closes after exactly one ~72-item
|
||||||
|
chunk, so `StreamSubmissions` reports `HasMore: false` immediately after
|
||||||
|
page 1.
|
||||||
|
4. **Expected behaviour:** FA's `/msg/submissions/` paginates via a
|
||||||
|
"Next 72" link encoding a from-id cursor; the iterator should follow it
|
||||||
|
across all ~66 pages for a 4718-item inbox.
|
||||||
|
5. **Reproduction:** logged-in client with a large submission inbox;
|
||||||
|
`count := 0; for range client.SubmissionInbox(ctx, ListOptions{}) { count++ }`
|
||||||
|
yields ~72, not the true total.
|
||||||
|
6. **Suspected layer:** HTML parsing `parseSubmissionInboxPage`. Its next-
|
||||||
|
cursor selector is `div.messagecenter-navigation a.button.more`; if FA
|
||||||
|
changed that markup the parser returns `nextURL == ""` and the iterator
|
||||||
|
stops after page 1. Check the selector against current `/msg/submissions/`
|
||||||
|
HTML (and the cursor-URL construction).
|
||||||
|
7. **What is NOT the SDK:** app side verified. `StreamSubmissions` ranges the
|
||||||
|
iterator fully on a single goroutine and streams everything it yields; it
|
||||||
|
passes `ListOptions{}` (no `MaxPages` cap). On-device logging shows the
|
||||||
|
crawl ends after one chunk the iterator simply stops yielding.
|
||||||
|
|
||||||
|
### #24 [x] Request logger drops `context.Context` (breaks trace propagation)
|
||||||
|
|
||||||
|
**Fixed in the SDK** `transport.go`'s `logRequest` now emits its record via
|
||||||
|
`logger.InfoContext(req.Context(), "fa.request", …)` instead of `logger.Info`,
|
||||||
|
so a context-aware `slog.Handler` recovers the caller's active span and the
|
||||||
|
HTTP span nests under the RPC span. A regression test,
|
||||||
|
`TestTransport_LogRequest_PropagatesRequestContext` in `transport_test.go`,
|
||||||
|
installs a context-capturing handler and asserts a sentinel value threaded
|
||||||
|
through `req.Context()` reaches the slog record guarding against a silent
|
||||||
|
revert to `Info`.
|
||||||
|
|
||||||
|
|
||||||
|
- **Where:** `go-fa-api` the HTTP transport's request logging (`transport.go`,
|
||||||
|
the `logRequest` helper / wherever `slog` records an outgoing request).
|
||||||
|
- **Type:** Enhancement, not a bug the SDK works correctly; this is a
|
||||||
|
one-line change the app needs for distributed tracing.
|
||||||
|
- **Symptom:** The app (FA Droid, WI-10) added OpenTelemetry spans. An app RPC
|
||||||
|
opens an `rpc` span and threads its `context.Context` into the SDK call. The
|
||||||
|
SDK's per-request `slog` line is currently emitted with `logger.Info(...)`,
|
||||||
|
which carries **no context** so the app's `slog` handler cannot recover the
|
||||||
|
active span, and each HTTP span becomes an unparented root instead of a child
|
||||||
|
of the RPC span.
|
||||||
|
- **Expected:** the request log record should carry the request's context so a
|
||||||
|
context-aware `slog.Handler` can read the active span from it.
|
||||||
|
- **The fix (one line):** change the request-logging call from
|
||||||
|
`logger.Info("fa.request", …)` to
|
||||||
|
`logger.InfoContext(req.Context(), "fa.request", …)` (use the `*http.Request`'s
|
||||||
|
own `Context()`). `slog` already supports `InfoContext`; no API change, no new
|
||||||
|
dependency.
|
||||||
|
- **Cause tag:** `sdk`
|
||||||
|
- **SDK handoff brief:**
|
||||||
|
1. **SDK entry point:** the HTTP transport `RoundTrip` / request path in
|
||||||
|
`transport.go` specifically the `slog` call that logs each outgoing FA
|
||||||
|
request (`logRequest`, or inline). All SDK client methods route through it.
|
||||||
|
2. **How the app calls it:** every `internal/services/*.go` wrapper calls a
|
||||||
|
`Client.*` method with a `context.Context` that now carries an OTel span;
|
||||||
|
that ctx reaches the `*http.Request` (`req.Context()`).
|
||||||
|
3. **Observed behaviour:** the request `slog` record is created without a
|
||||||
|
context, so `Handler.Handle` receives `context.Background()`.
|
||||||
|
4. **Expected behaviour:** the record carries `req.Context()`, so a
|
||||||
|
context-aware handler can extract the active span.
|
||||||
|
5. **Reproduction:** with WI-10's `diag` slog handler installed, every HTTP
|
||||||
|
span has `parentSpanId == ""` even when the call was made inside an RPC
|
||||||
|
span. After the `InfoContext` change, the HTTP span nests under the RPC
|
||||||
|
span on the same trace id.
|
||||||
|
6. **Suspected layer:** request shape / logging purely the logging call,
|
||||||
|
no parsing or auth involved.
|
||||||
|
7. **What is NOT the SDK behaviour-wise:** no functional SDK behaviour
|
||||||
|
changes request execution, parsing, retries, rate-limiting are all
|
||||||
|
untouched. This only changes which `slog` method is used so context flows.
|
||||||
|
- **Note:** WI-10 applied this change to the *local* `replace`d working copy of
|
||||||
|
`go-fa-api` so FA Droid v0.0.33 has working rpc→http span linkage. It is
|
||||||
|
**not committed in the SDK repo**. Until it is upstreamed, a clean SDK
|
||||||
|
checkout will degrade gracefully HTTP spans become valid but unparented
|
||||||
|
roots (no crash, no data loss, only the rpc↔http link is lost).
|
||||||
216
actions.go
Normal file
216
actions.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fav adds a submission to the logged-in user's favorites. Idempotent: if
|
||||||
|
// the submission is already favorited, the call is a no-op.
|
||||||
|
//
|
||||||
|
// Implementation note: FA gates this with a one-shot CSRF key that lives
|
||||||
|
// on the "+Fav" anchor on the submission page. We fetch the submission to
|
||||||
|
// scrape the key, then follow the link. The whole exchange happens
|
||||||
|
// through the rate-limited transport.
|
||||||
|
func (c *Client) Fav(ctx context.Context, id SubmissionID) error {
|
||||||
|
return c.toggleFavorite(ctx, id, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unfav removes a submission from the logged-in user's favorites.
|
||||||
|
// Idempotent: if not favorited, no-op.
|
||||||
|
func (c *Client) Unfav(ctx context.Context, id SubmissionID) error {
|
||||||
|
return c.toggleFavorite(ctx, id, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// toggleFavorite implements both Fav and Unfav: scrape the per-state link
|
||||||
|
// off the submission page and follow it. wantFav=true means "fav if not
|
||||||
|
// already faved"; wantFav=false means "unfav if currently faved".
|
||||||
|
func (c *Client) toggleFavorite(ctx context.Context, id SubmissionID, wantFav bool) error {
|
||||||
|
if id <= 0 {
|
||||||
|
return fmt.Errorf("fa: toggleFavorite: id must be > 0")
|
||||||
|
}
|
||||||
|
var actionURL string
|
||||||
|
var alreadyInDesiredState bool
|
||||||
|
err := c.fetch(ctx, urls.Submission(int64(id)), func(doc *goquery.Document) error {
|
||||||
|
fav, unfav := findFavLinks(doc, int64(id))
|
||||||
|
switch {
|
||||||
|
case wantFav && fav != "":
|
||||||
|
actionURL = fav
|
||||||
|
case wantFav && fav == "" && unfav != "":
|
||||||
|
alreadyInDesiredState = true
|
||||||
|
case !wantFav && unfav != "":
|
||||||
|
actionURL = unfav
|
||||||
|
case !wantFav && unfav == "" && fav != "":
|
||||||
|
alreadyInDesiredState = true
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: submission %d: no fav/unfav link on page (not logged in?)", ErrUnauthorized, id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if alreadyInDesiredState {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.followAction(ctx, actionURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch starts watching a user. Idempotent: if already watching, no-op.
|
||||||
|
// The key + endpoint live on a button on the user's profile (or any page
|
||||||
|
// that user is the "owner" of, like a journal). We scrape the profile.
|
||||||
|
func (c *Client) Watch(ctx context.Context, name string) error {
|
||||||
|
return c.toggleWatch(ctx, name, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwatch stops watching a user. Idempotent.
|
||||||
|
func (c *Client) Unwatch(ctx context.Context, name string) error {
|
||||||
|
return c.toggleWatch(ctx, name, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// toggleWatch fetches the user page, picks watch or unwatch link by state.
|
||||||
|
func (c *Client) toggleWatch(ctx context.Context, name string, wantWatch bool) error {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("fa: toggleWatch: empty name")
|
||||||
|
}
|
||||||
|
var actionURL string
|
||||||
|
var alreadyInDesiredState bool
|
||||||
|
err := c.fetch(ctx, urls.User(name), func(doc *goquery.Document) error {
|
||||||
|
watch, unwatch := findWatchLinks(doc, name)
|
||||||
|
switch {
|
||||||
|
case wantWatch && watch != "":
|
||||||
|
actionURL = watch
|
||||||
|
case wantWatch && watch == "" && unwatch != "":
|
||||||
|
alreadyInDesiredState = true
|
||||||
|
case !wantWatch && unwatch != "":
|
||||||
|
actionURL = unwatch
|
||||||
|
case !wantWatch && unwatch == "" && watch != "":
|
||||||
|
alreadyInDesiredState = true
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: user %q: no watch/unwatch link on page", ErrUnauthorized, name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if alreadyInDesiredState {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.followAction(ctx, actionURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findFavLinks scans a submission view page for the "+Fav" and "−Fav"
|
||||||
|
// anchors that FA renders side by side (only one is real at a time —
|
||||||
|
// whichever matches your current favorite state). Returns absolute URLs;
|
||||||
|
// either may be "" if the page is anonymous-mode or doesn't show controls.
|
||||||
|
func findFavLinks(doc *goquery.Document, subID int64) (favURL, unfavURL string) {
|
||||||
|
subStr := fmt.Sprintf("/fav/%d/", subID)
|
||||||
|
unfavStr := fmt.Sprintf("/unfav/%d/", subID)
|
||||||
|
doc.Find("a[href*='/fav/'], a[href*='/unfav/']").Each(func(_ int, a *goquery.Selection) {
|
||||||
|
href, _ := a.Attr("href")
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(href, subStr):
|
||||||
|
favURL = urls.AbsoluteCDN(href)
|
||||||
|
case strings.HasPrefix(href, unfavStr):
|
||||||
|
unfavURL = urls.AbsoluteCDN(href)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return favURL, unfavURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// findWatchLinks scans a user page for the Watch / Unwatch button.
|
||||||
|
// FA renders id="watch-button" on the active anchor with href
|
||||||
|
// "/watch/{name}/?key=..." or "/unwatch/{name}/?key=...".
|
||||||
|
func findWatchLinks(doc *goquery.Document, name string) (watchURL, unwatchURL string) {
|
||||||
|
wPrefix := "/watch/" + strings.ToLower(name) + "/"
|
||||||
|
uwPrefix := "/unwatch/" + strings.ToLower(name) + "/"
|
||||||
|
doc.Find("a[href*='/watch/'], a[href*='/unwatch/']").Each(func(_ int, a *goquery.Selection) {
|
||||||
|
href, _ := a.Attr("href")
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(href, wPrefix):
|
||||||
|
watchURL = urls.AbsoluteCDN(href)
|
||||||
|
case strings.HasPrefix(href, uwPrefix):
|
||||||
|
unwatchURL = urls.AbsoluteCDN(href)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return watchURL, unwatchURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// followAction performs a one-shot GET to a fav/watch/etc. action URL and
|
||||||
|
// classifies the response. FA typically redirects to the originating page
|
||||||
|
// on success (handled transparently by the stdlib client), so a 2xx final
|
||||||
|
// status means the action took effect.
|
||||||
|
//
|
||||||
|
// 4xx is surfaced as ErrUnauthorized / ErrNotFound / ErrSystemMessage via
|
||||||
|
// the transport+classifier; 5xx becomes HTTPError. We don't parse the
|
||||||
|
// response body FA's success states are too varied to verify reliably
|
||||||
|
// from HTML alone.
|
||||||
|
func (c *Client) followAction(ctx context.Context, actionURL string) error {
|
||||||
|
if actionURL == "" {
|
||||||
|
return fmt.Errorf("fa: followAction: empty URL")
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, actionURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Rate limit + retries are handled by the transport on c.http.
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Drain and classify. If the response is FA's System Error template we
|
||||||
|
// surface that; otherwise a 2xx response is taken as success.
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusFound {
|
||||||
|
// Parse out a possible system-message wrapper.
|
||||||
|
if doc, derr := goquery.NewDocumentFromReader(strings.NewReader(string(body))); derr == nil {
|
||||||
|
if smErr := classifySystemMessage(doc); smErr != nil {
|
||||||
|
return smErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &HTTPError{StatusCode: resp.StatusCode, URL: actionURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
// postForm is the shared form-POST helper used by PostComment, SendNote,
|
||||||
|
// and any other M3 write action. It honours the rate limiter, sets the
|
||||||
|
// proper Content-Type, and surfaces system-message errors from the
|
||||||
|
// response body.
|
||||||
|
//
|
||||||
|
// Returns the response body for callers that need to parse a confirmation
|
||||||
|
// (e.g., to extract a newly-posted comment's ID).
|
||||||
|
func (c *Client) postForm(ctx context.Context, rawURL string, form url.Values) ([]byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return body, &HTTPError{StatusCode: resp.StatusCode, URL: rawURL}
|
||||||
|
}
|
||||||
|
if doc, derr := goquery.NewDocumentFromReader(strings.NewReader(string(body))); derr == nil {
|
||||||
|
if smErr := classifySystemMessage(doc); smErr != nil {
|
||||||
|
return body, smErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
401
actions_test.go
Normal file
401
actions_test.go
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeSubmissionPage builds a minimal /view/{id}/ response that has either
|
||||||
|
// a Fav or Unfav anchor (controlled by faved). Sufficient to satisfy the
|
||||||
|
// classifySystemMessage check (title is non-System-Error) and the
|
||||||
|
// findFavLinks selector.
|
||||||
|
func fakeSubmissionPage(id int, faved bool) string {
|
||||||
|
prefix := "/fav/"
|
||||||
|
label := "+Fav"
|
||||||
|
if faved {
|
||||||
|
prefix = "/unfav/"
|
||||||
|
label = "-Fav"
|
||||||
|
}
|
||||||
|
href := prefix + strconv.Itoa(id) + "/?key=k1"
|
||||||
|
return `<html><head><title>Test submission</title></head><body>
|
||||||
|
<div class="submission-description-artist">
|
||||||
|
<a href="/user/x/"><img class="avatar" src="/a.png"/></a>
|
||||||
|
<div>
|
||||||
|
<div class="submission-title"><h2>T</h2></div>
|
||||||
|
<div><span class="c-usernameBlockSimple__displayName" title="x">x</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a class="button" href="` + href + `">` + label + `</a>
|
||||||
|
</body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fakeUserPage(name string, watching bool) string {
|
||||||
|
href := "/watch/" + name + "/?key=k2"
|
||||||
|
text := "Watch"
|
||||||
|
if watching {
|
||||||
|
href = "/unwatch/" + name + "/?key=k2"
|
||||||
|
text = "Unwatch"
|
||||||
|
}
|
||||||
|
return `<html><head><title>Userpage</title></head><body>
|
||||||
|
<div class="username"><h2><span>` + name + `</span></h2></div>
|
||||||
|
<a id="watch-button" class="button" href="` + href + `">` + text + `</a>
|
||||||
|
</body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFav_FetchesAndFollowsFavLink(t *testing.T) {
|
||||||
|
var hits atomic.Int32
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/view/123/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
_, _ = w.Write([]byte(fakeSubmissionPage(123, false)))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/fav/123/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
if r.URL.Query().Get("key") != "k1" {
|
||||||
|
t.Errorf("missing key on /fav/: %q", r.URL.RawQuery)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
if err := client.Fav(context.Background(), 123); err != nil {
|
||||||
|
t.Fatalf("Fav: %v", err)
|
||||||
|
}
|
||||||
|
if hits.Load() != 2 {
|
||||||
|
t.Errorf("hits = %d; want 2 (fetch + follow)", hits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFav_AlreadyFavedIsNoOp(t *testing.T) {
|
||||||
|
var follows atomic.Int32
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/view/77/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(fakeSubmissionPage(77, true)))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/fav/77/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
follows.Add(1)
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
if err := client.Fav(context.Background(), 77); err != nil {
|
||||||
|
t.Fatalf("Fav: %v", err)
|
||||||
|
}
|
||||||
|
if follows.Load() != 0 {
|
||||||
|
t.Errorf("/fav/ was hit %d times; want 0 (already faved)", follows.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnfav_FollowsUnfavLink(t *testing.T) {
|
||||||
|
var hits atomic.Int32
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/view/55/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(fakeSubmissionPage(55, true)))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/unfav/55/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
if r.URL.Query().Get("key") != "k1" {
|
||||||
|
t.Errorf("missing key on /unfav/")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
if err := client.Unfav(context.Background(), 55); err != nil {
|
||||||
|
t.Fatalf("Unfav: %v", err)
|
||||||
|
}
|
||||||
|
if hits.Load() != 1 {
|
||||||
|
t.Errorf("/unfav/ hits = %d; want 1", hits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatch_FollowsWatchLink(t *testing.T) {
|
||||||
|
var hits atomic.Int32
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/user/alice/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(fakeUserPage("alice", false)))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/watch/alice/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
if r.URL.Query().Get("key") != "k2" {
|
||||||
|
t.Errorf("missing key on /watch/")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
if err := client.Watch(context.Background(), "alice"); err != nil {
|
||||||
|
t.Fatalf("Watch: %v", err)
|
||||||
|
}
|
||||||
|
if hits.Load() != 1 {
|
||||||
|
t.Errorf("/watch/ hits = %d", hits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnwatch_AlreadyNotWatching_NoOp(t *testing.T) {
|
||||||
|
var follows atomic.Int32
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/user/bob/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(fakeUserPage("bob", false)))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/unwatch/bob/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
follows.Add(1)
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
if err := client.Unwatch(context.Background(), "bob"); err != nil {
|
||||||
|
t.Fatalf("Unwatch: %v", err)
|
||||||
|
}
|
||||||
|
if follows.Load() != 0 {
|
||||||
|
t.Errorf("/unwatch/ was hit; want no-op when not watching")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostSubmissionComment_POSTsCorrectForm(t *testing.T) {
|
||||||
|
var got url.Values
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/view/200/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "want POST", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
got, _ = url.ParseQuery(string(body))
|
||||||
|
_, _ = w.Write([]byte("<html><body><div class='comment-container'>posted</div></body></html>"))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
err := client.PostSubmissionComment(context.Background(), 200, "Nice work!", PostCommentOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PostSubmissionComment: %v", err)
|
||||||
|
}
|
||||||
|
if got.Get("action") != "reply" {
|
||||||
|
t.Errorf("action = %q", got.Get("action"))
|
||||||
|
}
|
||||||
|
if got.Get("reply") != "Nice work!" {
|
||||||
|
t.Errorf("reply = %q", got.Get("reply"))
|
||||||
|
}
|
||||||
|
if got.Get("replyto") != "" {
|
||||||
|
t.Errorf("replyto should be empty for top-level; got %q", got.Get("replyto"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostSubmissionComment_ReplytoSetForThreadedReply(t *testing.T) {
|
||||||
|
var got url.Values
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/view/200/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
got, _ = url.ParseQuery(string(body))
|
||||||
|
_, _ = w.Write([]byte("<html><body>ok</body></html>"))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
err := client.PostSubmissionComment(context.Background(), 200, "reply text", PostCommentOptions{ParentID: 999})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PostSubmissionComment: %v", err)
|
||||||
|
}
|
||||||
|
if got.Get("replyto") != "999" {
|
||||||
|
t.Errorf("replyto = %q; want 999", got.Get("replyto"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostJournalComment_TargetsJournalURL(t *testing.T) {
|
||||||
|
var hit string
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/journal/400/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hit = r.URL.Path
|
||||||
|
_, _ = w.Write([]byte("<html><body>ok</body></html>"))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
err := client.PostJournalComment(context.Background(), 400, "hi", PostCommentOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PostJournalComment: %v", err)
|
||||||
|
}
|
||||||
|
if hit != "/journal/400/" {
|
||||||
|
t.Errorf("hit = %q", hit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendNote_ScrapesKeyAndPosts(t *testing.T) {
|
||||||
|
inboxHTML := `<html><head><title>Inbox</title></head><body>
|
||||||
|
<form action="/msg/send/"><input type="hidden" name="key" value="SCRAPED_KEY"/></form>
|
||||||
|
</body></html>`
|
||||||
|
var sendBody url.Values
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/msg/pms/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(inboxHTML))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/msg/send/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("want POST, got %s", r.Method)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
sendBody, _ = url.ParseQuery(string(body))
|
||||||
|
_, _ = w.Write([]byte("<html><body>sent</body></html>"))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
err := client.SendNote(context.Background(), "vampexx", "Re: hi", "body text")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendNote: %v", err)
|
||||||
|
}
|
||||||
|
if sendBody.Get("key") != "SCRAPED_KEY" {
|
||||||
|
t.Errorf("key = %q; want SCRAPED_KEY", sendBody.Get("key"))
|
||||||
|
}
|
||||||
|
if sendBody.Get("to") != "vampexx" {
|
||||||
|
t.Errorf("to = %q", sendBody.Get("to"))
|
||||||
|
}
|
||||||
|
if sendBody.Get("subject") != "Re: hi" {
|
||||||
|
t.Errorf("subject = %q", sendBody.Get("subject"))
|
||||||
|
}
|
||||||
|
if sendBody.Get("message") != "body text" {
|
||||||
|
t.Errorf("message = %q", sendBody.Get("message"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendNote_NoKeyOnInboxPage_Unauthorized(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/msg/pms/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`<html><head><title>Inbox</title></head><body>no form here</body></html>`))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
err := client.SendNote(context.Background(), "x", "s", "b")
|
||||||
|
if !errors.Is(err, ErrUnauthorized) {
|
||||||
|
t.Fatalf("got %v; want ErrUnauthorized", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildBrowseURL_NoFiltersUsesSimpleForm(t *testing.T) {
|
||||||
|
u := buildBrowseURL(1, BrowseOptions{})
|
||||||
|
if u != "https://www.furaffinity.net/browse/" {
|
||||||
|
t.Errorf("page=1 default = %q", u)
|
||||||
|
}
|
||||||
|
u = buildBrowseURL(2, BrowseOptions{})
|
||||||
|
if u != "https://www.furaffinity.net/browse/?page=2" {
|
||||||
|
t.Errorf("page=2 default = %q", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildBrowseURL_RatingsAndCategoryFilters(t *testing.T) {
|
||||||
|
got := buildBrowseURL(1, BrowseOptions{
|
||||||
|
Ratings: []Rating{RatingGeneral, RatingMature},
|
||||||
|
Category: 2,
|
||||||
|
PerPage: 48,
|
||||||
|
})
|
||||||
|
q := mustQuery(t, got)
|
||||||
|
if q.Get("rating_general") != "1" || q.Get("rating_mature") != "1" {
|
||||||
|
t.Errorf("ratings not encoded: %v", q)
|
||||||
|
}
|
||||||
|
if q.Get("rating_adult") != "" {
|
||||||
|
t.Errorf("rating_adult should not be set when not requested")
|
||||||
|
}
|
||||||
|
if q.Get("cat") != "2" {
|
||||||
|
t.Errorf("cat = %q", q.Get("cat"))
|
||||||
|
}
|
||||||
|
if q.Get("perpage") != "48" {
|
||||||
|
t.Errorf("perpage = %q", q.Get("perpage"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2E_Browse_RatingsFilterFlowsThroughIterator(t *testing.T) {
|
||||||
|
var seenRatings []string
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/browse/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seenRatings = []string{
|
||||||
|
r.URL.Query().Get("rating_general"),
|
||||||
|
r.URL.Query().Get("rating_mature"),
|
||||||
|
r.URL.Query().Get("rating_adult"),
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = w.Write([]byte(`<html><body></body></html>`))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
for _, err := range client.Browse(context.Background(), BrowseOptions{
|
||||||
|
Ratings: []Rating{RatingGeneral},
|
||||||
|
}) {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("iter: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seenRatings[0] != "1" || seenRatings[1] != "" || seenRatings[2] != "" {
|
||||||
|
t.Errorf("seenRatings = %v; want [1, '', '']", seenRatings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindFavLinks_RealFixture confirms findFavLinks scrapes the +Fav anchor
|
||||||
|
// out of a real captured /view/ page. Guards against FA changing the fav-link
|
||||||
|
// markup (issue #5: favourite action doing nothing).
|
||||||
|
func TestFindFavLinks_RealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "submission.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
// submission.html was captured for submission 65052636 (a "+Fav" page).
|
||||||
|
favURL, unfavURL := findFavLinks(doc, 65052636)
|
||||||
|
if favURL == "" {
|
||||||
|
t.Error("findFavLinks: favURL empty +Fav anchor not found in real markup")
|
||||||
|
}
|
||||||
|
if !strings.Contains(favURL, "/fav/65052636/") || !strings.Contains(favURL, "key=") {
|
||||||
|
t.Errorf("findFavLinks: favURL = %q; want a /fav/65052636/?key=... URL", favURL)
|
||||||
|
}
|
||||||
|
if unfavURL != "" {
|
||||||
|
t.Errorf("findFavLinks: unfavURL = %q; want empty on a not-yet-faved page", unfavURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindWatchLinks_RealFixture confirms findWatchLinks scrapes the
|
||||||
|
// watch-button anchor out of a real captured userpage-style page. Guards
|
||||||
|
// against FA changing the watch-button markup (issue #10).
|
||||||
|
func TestFindWatchLinks_RealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "gallery_page1.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
// gallery_page1.html was captured for kazucreations, whom the capturing
|
||||||
|
// account watches so the header shows an Unwatch button.
|
||||||
|
watchURL, unwatchURL := findWatchLinks(doc, "kazucreations")
|
||||||
|
if unwatchURL == "" {
|
||||||
|
t.Error("findWatchLinks: unwatchURL empty watch-button anchor not found in real markup")
|
||||||
|
}
|
||||||
|
if unwatchURL != "" && (!strings.Contains(unwatchURL, "/unwatch/kazucreations/") || !strings.Contains(unwatchURL, "key=")) {
|
||||||
|
t.Errorf("findWatchLinks: unwatchURL = %q; want a /unwatch/kazucreations/?key=... URL", unwatchURL)
|
||||||
|
}
|
||||||
|
if watchURL != "" {
|
||||||
|
t.Errorf("findWatchLinks: watchURL = %q; want empty when already watching", watchURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
148
browse.go
Normal file
148
browse.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"iter"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// browsePerPage is FA's default page size for /browse/. We use it as the
|
||||||
|
// "this page is full → there's likely another one" threshold because the
|
||||||
|
// browse feed paginates via POST forms in the UI; detectNextPage's anchor-
|
||||||
|
// based heuristic doesn't fire on browse responses.
|
||||||
|
const browsePerPage = 72
|
||||||
|
|
||||||
|
// BrowseOptions configures /browse/ filters. Zero-value defaults match
|
||||||
|
// FA's web UI default: all ratings on, all categories, all art types,
|
||||||
|
// any species, 72 per page.
|
||||||
|
//
|
||||||
|
// The filter fields use FA's internal numeric IDs (1 = "All" / "Any" for
|
||||||
|
// category, atype, species). The exact list of valid values is huge and
|
||||||
|
// changes over time; for now we expose them as raw ints rather than
|
||||||
|
// pretending to enumerate them callers wanting a typed Category enum
|
||||||
|
// should grab the value off the browse form's <option> elements.
|
||||||
|
type BrowseOptions struct {
|
||||||
|
// Ratings restricts results to these rating levels. nil/empty = all
|
||||||
|
// three (general + mature + adult).
|
||||||
|
Ratings []Rating
|
||||||
|
|
||||||
|
// Category, ArtType, Species are FA's internal numeric IDs. Zero or 1
|
||||||
|
// means "All / Any" (the default).
|
||||||
|
Category int
|
||||||
|
ArtType int
|
||||||
|
Species int
|
||||||
|
|
||||||
|
// PerPage is the page size. Valid values match the web UI: 24/36/48/60/72.
|
||||||
|
// Zero defaults to 72.
|
||||||
|
PerPage int
|
||||||
|
|
||||||
|
// StartPage is the 1-based page to begin iteration on.
|
||||||
|
StartPage int
|
||||||
|
|
||||||
|
// MaxPages bounds the iterator. Zero = unbounded (be careful the feed
|
||||||
|
// is effectively infinite).
|
||||||
|
MaxPages int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browse iterates FA's global front-page feed (/browse/), newest first.
|
||||||
|
// Returns the same partially-populated *Submission shape as [Client.Gallery]:
|
||||||
|
// ID, Title, Author, ThumbURL, Rating. Call [Client.GetSubmission] with
|
||||||
|
// the ID to load the full record.
|
||||||
|
//
|
||||||
|
// The feed is effectively unbounded (millions of submissions across the
|
||||||
|
// site). Always set [BrowseOptions.MaxPages] in production code; without
|
||||||
|
// it the iterator will keep fetching until FA returns a partial page.
|
||||||
|
//
|
||||||
|
// Note: FA paginates browse via POST forms in the UI; this iterator
|
||||||
|
// instead uses GET with ?page=N, which is honoured for the rendered HTML.
|
||||||
|
// "Next page exists?" is inferred from item count: a full page
|
||||||
|
// (browsePerPage items) means we keep going; anything less is the tail.
|
||||||
|
func (c *Client) Browse(ctx context.Context, opts BrowseOptions) iter.Seq2[*Submission, error] {
|
||||||
|
return func(yield func(*Submission, error) bool) {
|
||||||
|
page := opts.StartPage
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
pagesFetched := 0
|
||||||
|
for {
|
||||||
|
if opts.MaxPages > 0 && pagesFetched >= opts.MaxPages {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageURL := buildBrowseURL(page, opts)
|
||||||
|
var items []*Submission
|
||||||
|
err := c.fetch(ctx, pageURL, func(doc *goquery.Document) error {
|
||||||
|
items, _ = parseGalleryPage(doc, c.cfg.jsonListings)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
yield(nil, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pagesFetched++
|
||||||
|
if len(items) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, s := range items {
|
||||||
|
if !yield(s, nil) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(items) < browsePerPage {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildBrowseURL constructs a /browse/ URL with the given page and any
|
||||||
|
// non-default filter fields. The form fields match what FA's UI POSTs on
|
||||||
|
// the front-page form; we send them as GET params instead.
|
||||||
|
func buildBrowseURL(page int, opts BrowseOptions) string {
|
||||||
|
// Without any filters set, the simple path-form URL works.
|
||||||
|
hasFilters := len(opts.Ratings) != 0 ||
|
||||||
|
opts.Category > 1 || opts.ArtType > 1 || opts.Species > 1 ||
|
||||||
|
opts.PerPage > 0
|
||||||
|
if !hasFilters {
|
||||||
|
return urls.Browse(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
v := url.Values{}
|
||||||
|
if page > 1 {
|
||||||
|
v.Set("page", strconv.Itoa(page))
|
||||||
|
}
|
||||||
|
if opts.PerPage > 0 {
|
||||||
|
v.Set("perpage", strconv.Itoa(opts.PerPage))
|
||||||
|
}
|
||||||
|
if opts.Category > 0 {
|
||||||
|
v.Set("cat", strconv.Itoa(opts.Category))
|
||||||
|
}
|
||||||
|
if opts.ArtType > 0 {
|
||||||
|
v.Set("atype", strconv.Itoa(opts.ArtType))
|
||||||
|
}
|
||||||
|
if opts.Species > 0 {
|
||||||
|
v.Set("species", strconv.Itoa(opts.Species))
|
||||||
|
}
|
||||||
|
|
||||||
|
ratings := opts.Ratings
|
||||||
|
if len(ratings) == 0 {
|
||||||
|
ratings = []Rating{RatingGeneral, RatingMature, RatingAdult}
|
||||||
|
}
|
||||||
|
for _, r := range ratings {
|
||||||
|
switch r {
|
||||||
|
case RatingGeneral:
|
||||||
|
v.Set("rating_general", "1")
|
||||||
|
case RatingMature:
|
||||||
|
v.Set("rating_mature", "1")
|
||||||
|
case RatingAdult:
|
||||||
|
v.Set("rating_adult", "1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls.Host + "/browse/?" + v.Encode()
|
||||||
|
}
|
||||||
185
client.go
Normal file
185
client.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
"github.com/gocolly/colly/v2"
|
||||||
|
|
||||||
|
farouting "git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is the entry point of the SDK. It is safe for concurrent use; the
|
||||||
|
// internal rate limiter serializes outbound requests regardless of the
|
||||||
|
// number of calling goroutines.
|
||||||
|
//
|
||||||
|
// One Client corresponds to one FA session. Construct anonymous and
|
||||||
|
// authenticated clients separately rather than mutating one in-flight.
|
||||||
|
type Client struct {
|
||||||
|
cfg config
|
||||||
|
limiter *rateLimiter
|
||||||
|
logger *slog.Logger
|
||||||
|
collector *colly.Collector
|
||||||
|
http *http.Client
|
||||||
|
jar http.CookieJar
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a configured Client. Pass options to override defaults.
|
||||||
|
//
|
||||||
|
// client := fa.New(
|
||||||
|
// fa.WithCookies(fa.Cookies{A: aCookie, B: bCookie}),
|
||||||
|
// fa.WithUserAgent("myapp/1.0"),
|
||||||
|
// )
|
||||||
|
func New(opts ...Option) *Client {
|
||||||
|
cfg := config{
|
||||||
|
userAgent: defaultUserAgent,
|
||||||
|
// One request per second steady-state, but allow a small burst so
|
||||||
|
// that e.g. avatar enrichment (one fetch per distinct author) can
|
||||||
|
// fire a few requests back-to-back before the 1/s pacing kicks in.
|
||||||
|
rateInterval: time.Second,
|
||||||
|
rateBurst: 3,
|
||||||
|
logger: slog.Default(),
|
||||||
|
maxRetries: defaultMaxRetries,
|
||||||
|
}
|
||||||
|
for _, o := range opts {
|
||||||
|
o(&cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
limiter := newRateLimiter(cfg.rateInterval, cfg.rateBurst, cfg.priorityRL)
|
||||||
|
|
||||||
|
// Build the base RoundTripper. If caller supplied an http.Client, reuse
|
||||||
|
// its transport as the "base" so that any TLS customisation (uTLS,
|
||||||
|
// chromedp, etc.) still applies. Otherwise wrap the stdlib default.
|
||||||
|
var baseRT http.RoundTripper = http.DefaultTransport
|
||||||
|
if cfg.httpClient != nil && cfg.httpClient.Transport != nil {
|
||||||
|
baseRT = cfg.httpClient.Transport
|
||||||
|
}
|
||||||
|
rt := &transport{
|
||||||
|
base: baseRT,
|
||||||
|
limiter: limiter,
|
||||||
|
userAgent: cfg.userAgent,
|
||||||
|
maxRetries: cfg.maxRetries,
|
||||||
|
logger: cfg.logger,
|
||||||
|
}
|
||||||
|
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
|
seedJar(jar, cfg.cookies, cfg.cf, cfg.sfw)
|
||||||
|
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: rt,
|
||||||
|
Jar: jar,
|
||||||
|
}
|
||||||
|
if cfg.httpClient != nil {
|
||||||
|
httpClient.Timeout = cfg.httpClient.Timeout
|
||||||
|
httpClient.CheckRedirect = cfg.httpClient.CheckRedirect
|
||||||
|
}
|
||||||
|
|
||||||
|
base := colly.NewCollector(
|
||||||
|
colly.UserAgent(cfg.userAgent),
|
||||||
|
colly.AllowURLRevisit(),
|
||||||
|
)
|
||||||
|
base.SetClient(httpClient)
|
||||||
|
base.SetCookieJar(jar)
|
||||||
|
// Colly's own LimitRule would compose with our transport limiter and
|
||||||
|
// double-throttle requests; instead, leave Colly unthrottled and let the
|
||||||
|
// transport be the single source of pacing truth.
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
cfg: cfg,
|
||||||
|
limiter: limiter,
|
||||||
|
logger: cfg.logger,
|
||||||
|
collector: base,
|
||||||
|
http: httpClient,
|
||||||
|
jar: jar,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedJar installs the FA session and Cloudflare clearance cookies onto the
|
||||||
|
// cookie jar so every outbound request to the host picks them up. The
|
||||||
|
// stdlib jar requires a URL to scope cookies; we use the FA host root.
|
||||||
|
//
|
||||||
|
// When sfw is [SFWOn] or [SFWOff] the `sfw` cookie is set to "1" or "0"
|
||||||
|
// respectively, matching what FA's navbar slider writes client-side.
|
||||||
|
// [SFWAuto] leaves the cookie unset so the account default applies.
|
||||||
|
func seedJar(jar http.CookieJar, fa Cookies, cf CFCookies, sfw SFWMode) {
|
||||||
|
hostURL, err := url.Parse(farouting.Host)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cookies []*http.Cookie
|
||||||
|
if fa.A != "" {
|
||||||
|
cookies = append(cookies, &http.Cookie{Name: "a", Value: fa.A, Path: "/"})
|
||||||
|
}
|
||||||
|
if fa.B != "" {
|
||||||
|
cookies = append(cookies, &http.Cookie{Name: "b", Value: fa.B, Path: "/"})
|
||||||
|
}
|
||||||
|
if cf.Clearance != "" {
|
||||||
|
cookies = append(cookies, &http.Cookie{Name: "cf_clearance", Value: cf.Clearance, Path: "/"})
|
||||||
|
}
|
||||||
|
switch sfw {
|
||||||
|
case SFWOn:
|
||||||
|
cookies = append(cookies, &http.Cookie{Name: "sfw", Value: "1", Path: "/"})
|
||||||
|
case SFWOff:
|
||||||
|
cookies = append(cookies, &http.Cookie{Name: "sfw", Value: "0", Path: "/"})
|
||||||
|
}
|
||||||
|
if len(cookies) > 0 {
|
||||||
|
jar.SetCookies(hostURL, cookies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch executes a single GET via the internal Colly collector and hands the
|
||||||
|
// parsed goquery document to parse. The collector clone scopes the OnHTML/
|
||||||
|
// OnResponse callbacks to this single call, so concurrent calls do not see
|
||||||
|
// each other's responses.
|
||||||
|
//
|
||||||
|
// Context cancellation propagates through the http.Request and the rate
|
||||||
|
// limiter a cancelled ctx surfaces from Wait or from the underlying
|
||||||
|
// transport, depending on which phase the request is in.
|
||||||
|
func (c *Client) fetch(ctx context.Context, rawURL string, parse func(doc *goquery.Document) error) error {
|
||||||
|
clone := c.collector.Clone()
|
||||||
|
clone.SetClient(c.http)
|
||||||
|
clone.SetCookieJar(c.jar)
|
||||||
|
clone.Context = ctx
|
||||||
|
|
||||||
|
var (
|
||||||
|
parseErr error
|
||||||
|
respErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
clone.OnResponse(func(r *colly.Response) {
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(r.Body))
|
||||||
|
if err != nil {
|
||||||
|
parseErr = fmt.Errorf("%w: build document: %v", ErrParse, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if smErr := classifySystemMessage(doc); smErr != nil {
|
||||||
|
parseErr = smErr
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := parse(doc); err != nil {
|
||||||
|
parseErr = err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
clone.OnError(func(r *colly.Response, err error) {
|
||||||
|
respErr = err
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := clone.Visit(rawURL); err != nil {
|
||||||
|
if respErr != nil {
|
||||||
|
return respErr
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if respErr != nil {
|
||||||
|
return respErr
|
||||||
|
}
|
||||||
|
return parseErr
|
||||||
|
}
|
||||||
60
comment.go
Normal file
60
comment.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"iter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Comment is one entry from a submission's or journal's comment thread.
|
||||||
|
// FA's beta theme renders the thread as a flat indented list; the parser
|
||||||
|
// fills Depth and Parent so callers can rebuild the tree if needed.
|
||||||
|
type Comment struct {
|
||||||
|
ID CommentID
|
||||||
|
Author UserRef
|
||||||
|
PostedAt time.Time
|
||||||
|
BodyHTML string
|
||||||
|
BodyText string
|
||||||
|
Depth int // 0 = top level
|
||||||
|
Parent CommentID // 0 when Depth is 0
|
||||||
|
Deleted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmissionComments yields every comment on a submission's view page.
|
||||||
|
// Comments aren't paginated, so this iterator performs one fetch and then
|
||||||
|
// yields each comment in document order; early termination still avoids
|
||||||
|
// processing the rest of the slice.
|
||||||
|
func (c *Client) SubmissionComments(ctx context.Context, id SubmissionID) iter.Seq2[*Comment, error] {
|
||||||
|
return c.yieldComments(ctx, urls.Submission(int64(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// JournalComments yields every comment on a journal page. Same iteration
|
||||||
|
// shape as [Client.SubmissionComments].
|
||||||
|
func (c *Client) JournalComments(ctx context.Context, id JournalID) iter.Seq2[*Comment, error] {
|
||||||
|
return c.yieldComments(ctx, urls.Journal(int64(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// yieldComments performs the single fetch shared by submission and journal
|
||||||
|
// comment iterators, then yields parsed comments to the caller.
|
||||||
|
func (c *Client) yieldComments(ctx context.Context, pageURL string) iter.Seq2[*Comment, error] {
|
||||||
|
return func(yield func(*Comment, error) bool) {
|
||||||
|
var comments []*Comment
|
||||||
|
err := c.fetch(ctx, pageURL, func(doc *goquery.Document) error {
|
||||||
|
comments = parseComments(doc)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
yield(nil, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, cm := range comments {
|
||||||
|
if !yield(cm, nil) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
131
comment_parser.go
Normal file
131
comment_parser.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseComments walks a submission or journal page's comment section and
|
||||||
|
// returns the comments in document order. Depth and Parent are inferred from
|
||||||
|
// data-attributes when present, otherwise from the legacy "width: NN%" style
|
||||||
|
// FA still emits on threaded replies.
|
||||||
|
func parseComments(doc *goquery.Document) []*Comment {
|
||||||
|
var out []*Comment
|
||||||
|
|
||||||
|
doc.Find("div.comment-container, div[id^='cid:'], div[id^='comment-']").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
idAttr, _ := sel.Attr("id")
|
||||||
|
idStr := strings.TrimPrefix(idAttr, "cid:")
|
||||||
|
idStr = strings.TrimPrefix(idStr, "comment-")
|
||||||
|
id, _ := parseID[CommentID](idStr)
|
||||||
|
|
||||||
|
c := &Comment{ID: id}
|
||||||
|
|
||||||
|
// Deleted comments: class on the container.
|
||||||
|
if class, _ := sel.Attr("class"); strings.Contains(class, "comment-deleted") ||
|
||||||
|
strings.Contains(class, "deleted-comment") {
|
||||||
|
c.Deleted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depth: prefer data-depth, then class "c-1/c-2/...", then style width %.
|
||||||
|
if d, ok := sel.Attr("data-depth"); ok {
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(d)); err == nil {
|
||||||
|
c.Depth = n
|
||||||
|
}
|
||||||
|
} else if class, _ := sel.Attr("class"); class != "" {
|
||||||
|
for _, tok := range strings.Fields(class) {
|
||||||
|
if strings.HasPrefix(tok, "c-") {
|
||||||
|
if n, err := strconv.Atoi(strings.TrimPrefix(tok, "c-")); err == nil {
|
||||||
|
c.Depth = n
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Depth == 0 {
|
||||||
|
if style, ok := sel.Attr("style"); ok {
|
||||||
|
c.Depth = depthFromWidthStyle(style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent: from data-parent or replyto-* class.
|
||||||
|
if p, ok := sel.Attr("data-parent"); ok {
|
||||||
|
if n, err := parseID[CommentID](strings.TrimSpace(p)); err == nil {
|
||||||
|
c.Parent = n
|
||||||
|
}
|
||||||
|
} else if class, _ := sel.Attr("class"); class != "" {
|
||||||
|
for _, tok := range strings.Fields(class) {
|
||||||
|
if strings.HasPrefix(tok, "replyto-") {
|
||||||
|
if n, err := parseID[CommentID](strings.TrimPrefix(tok, "replyto-")); err == nil {
|
||||||
|
c.Parent = n
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Author.
|
||||||
|
authorSel := sel.Find("a.iconusername, .comment-username a, .c-usernameBlock a").First()
|
||||||
|
if authorSel.Length() > 0 {
|
||||||
|
href, _ := authorSel.Attr("href")
|
||||||
|
c.Author = UserRef{
|
||||||
|
DisplayName: trimText(authorSel),
|
||||||
|
AvatarURL: urls.AbsoluteCDN(trimAttr(authorSel.Find("img").First(), "src")),
|
||||||
|
}
|
||||||
|
if parts := strings.Split(strings.Trim(href, "/"), "/"); len(parts) >= 2 {
|
||||||
|
c.Author.Name = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date.
|
||||||
|
dateSel := sel.Find("span.popup_date").First()
|
||||||
|
if t, err := ParseFADate(firstNonEmpty(trimAttr(dateSel, "title"), trimText(dateSel))); err == nil {
|
||||||
|
c.PostedAt = t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body.
|
||||||
|
body := sel.Find("div.comment-user-text, div.user-text, .no_overflow").First()
|
||||||
|
c.BodyHTML = htmlOf(body)
|
||||||
|
c.BodyText = strings.TrimSpace(body.Text())
|
||||||
|
|
||||||
|
if c.ID != 0 || c.Author.DisplayName != "" || c.BodyText != "" {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// depthFromWidthStyle reads a legacy FA inline style like
|
||||||
|
// "width: 96%; padding: ..." and maps it to a depth level. FA used to shrink
|
||||||
|
// each reply by 3% per level, which is how earlier scrapers detected depth.
|
||||||
|
// Returns 0 if no usable width found.
|
||||||
|
func depthFromWidthStyle(style string) int {
|
||||||
|
low := strings.ToLower(style)
|
||||||
|
i := strings.Index(low, "width:")
|
||||||
|
if i == -1 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
rest := low[i+len("width:"):]
|
||||||
|
end := strings.Index(rest, "%")
|
||||||
|
if end == -1 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
numStr := strings.TrimSpace(rest[:end])
|
||||||
|
num, err := strconv.Atoi(numStr)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// 100% or 99% -> depth 0; each 3% step is one level deeper.
|
||||||
|
switch {
|
||||||
|
case num >= 99:
|
||||||
|
return 0
|
||||||
|
case num <= 0:
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
return (99 - num) / 3
|
||||||
|
}
|
||||||
|
}
|
||||||
83
comment_parser_test.go
Normal file
83
comment_parser_test.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
)
|
||||||
|
|
||||||
|
const syntheticCommentsHTML = `<html><body>
|
||||||
|
<div id="cid:111" class="comment-container c-0" data-depth="0">
|
||||||
|
<a class="iconusername" href="/user/alice/"><img src="//d.example/a.png"/>Alice</a>
|
||||||
|
<span class="popup_date" title="Apr 5, 2026 01:00 PM">today</span>
|
||||||
|
<div class="comment-user-text"><p>First!</p></div>
|
||||||
|
</div>
|
||||||
|
<div id="cid:112" class="comment-container c-1 replyto-111" data-depth="1" data-parent="111">
|
||||||
|
<a class="iconusername" href="/user/bob/"><img src="//d.example/b.png"/>Bob</a>
|
||||||
|
<span class="popup_date" title="Apr 5, 2026 01:05 PM">today</span>
|
||||||
|
<div class="comment-user-text"><p>Reply to first</p></div>
|
||||||
|
</div>
|
||||||
|
<div id="cid:113" class="comment-container comment-deleted c-0">
|
||||||
|
<span class="popup_date" title="Apr 5, 2026 01:10 PM">today</span>
|
||||||
|
<div class="comment-user-text">[deleted]</div>
|
||||||
|
</div>
|
||||||
|
</body></html>`
|
||||||
|
|
||||||
|
func TestParseComments_Synthetic(t *testing.T) {
|
||||||
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(syntheticCommentsHTML))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
cs := parseComments(doc)
|
||||||
|
if len(cs) != 3 {
|
||||||
|
t.Fatalf("comments = %d; want 3", len(cs))
|
||||||
|
}
|
||||||
|
if cs[0].ID != 111 || cs[0].Author.Name != "alice" || cs[0].Depth != 0 || cs[0].Parent != 0 {
|
||||||
|
t.Errorf("comment[0] = %+v", cs[0])
|
||||||
|
}
|
||||||
|
if cs[1].ID != 112 || cs[1].Author.Name != "bob" || cs[1].Depth != 1 || cs[1].Parent != 111 {
|
||||||
|
t.Errorf("comment[1] = %+v", cs[1])
|
||||||
|
}
|
||||||
|
if !cs[2].Deleted {
|
||||||
|
t.Errorf("comment[2].Deleted = false; want true")
|
||||||
|
}
|
||||||
|
if !strings.Contains(cs[0].BodyText, "First!") {
|
||||||
|
t.Errorf("comment[0].BodyText = %q", cs[0].BodyText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDepthFromWidthStyle(t *testing.T) {
|
||||||
|
cases := map[string]int{
|
||||||
|
"width: 99%; padding: 0": 0,
|
||||||
|
"width:96%": 1,
|
||||||
|
"width: 93%": 2,
|
||||||
|
"width: 90%": 3,
|
||||||
|
"width: 50%": 16,
|
||||||
|
"width: 100%": 0,
|
||||||
|
"no width here": 0,
|
||||||
|
"width: abc%": 0,
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := depthFromWidthStyle(in); got != want {
|
||||||
|
t.Errorf("depthFromWidthStyle(%q) = %d; want %d", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseComments_RealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "comments_submission.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
cs := parseComments(doc)
|
||||||
|
// Comments may be empty on a submission with no comments; just ensure
|
||||||
|
// the parser doesn't crash and yields sane values when populated.
|
||||||
|
for i, c := range cs {
|
||||||
|
if c.Depth < 0 {
|
||||||
|
t.Errorf("comment %d: negative depth %d", i, c.Depth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
comments_post.go
Normal file
62
comments_post.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PostCommentOptions configures a comment post. ParentID is the comment
|
||||||
|
// being replied to (0 for a top-level comment on the submission/journal).
|
||||||
|
type PostCommentOptions struct {
|
||||||
|
ParentID CommentID
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostSubmissionComment posts a comment on a submission. Body is BBCode-
|
||||||
|
// formatted text (FA's comment markup). Returns nil on success; an
|
||||||
|
// [ErrUnauthorized] when called without auth cookies; an
|
||||||
|
// [*SystemMessageError] if FA refused the post (rate limiter, blocked
|
||||||
|
// user, etc).
|
||||||
|
//
|
||||||
|
// FA's comment form (`#add_comment_form`) posts to the submission's own
|
||||||
|
// URL with fields `action=reply`, `replyto=<parent>`, `reply=<body>`.
|
||||||
|
// There is no separate per-form CSRF key auth cookies + Cloudflare
|
||||||
|
// clearance are the only gates.
|
||||||
|
func (c *Client) PostSubmissionComment(ctx context.Context, id SubmissionID, body string, opts PostCommentOptions) error {
|
||||||
|
if id <= 0 {
|
||||||
|
return fmt.Errorf("fa: PostSubmissionComment: id must be > 0")
|
||||||
|
}
|
||||||
|
return c.postCommentForm(ctx, urls.Submission(int64(id)), body, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostJournalComment posts a comment on a journal. Same form shape as
|
||||||
|
// submissions; the form action just points at /journal/{id}/.
|
||||||
|
func (c *Client) PostJournalComment(ctx context.Context, id JournalID, body string, opts PostCommentOptions) error {
|
||||||
|
if id <= 0 {
|
||||||
|
return fmt.Errorf("fa: PostJournalComment: id must be > 0")
|
||||||
|
}
|
||||||
|
return c.postCommentForm(ctx, urls.Journal(int64(id)), body, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// postCommentForm builds the field set #add_comment_form sends. Shared
|
||||||
|
// between submission and journal comment posting because FA renders an
|
||||||
|
// identical form on both pages.
|
||||||
|
func (c *Client) postCommentForm(ctx context.Context, pageURL, body string, opts PostCommentOptions) error {
|
||||||
|
if body == "" {
|
||||||
|
return fmt.Errorf("fa: PostComment: empty body")
|
||||||
|
}
|
||||||
|
v := url.Values{}
|
||||||
|
v.Set("f", "0")
|
||||||
|
v.Set("action", "reply")
|
||||||
|
if opts.ParentID > 0 {
|
||||||
|
v.Set("replyto", opts.ParentID.String())
|
||||||
|
} else {
|
||||||
|
v.Set("replyto", "")
|
||||||
|
}
|
||||||
|
v.Set("reply", body)
|
||||||
|
v.Set("submit", "Post Comment")
|
||||||
|
_, err := c.postForm(ctx, pageURL, v)
|
||||||
|
return err
|
||||||
|
}
|
||||||
42
doc.go
Normal file
42
doc.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// Package fa is a Go SDK for FurAffinity (https://www.furaffinity.net).
|
||||||
|
//
|
||||||
|
// FurAffinity exposes no JSON API, so this package scrapes the rendered
|
||||||
|
// HTML of the site's beta theme using Colly internally, and presents a
|
||||||
|
// strongly typed surface to callers.
|
||||||
|
//
|
||||||
|
// # Quickstart
|
||||||
|
//
|
||||||
|
// client := fa.New(
|
||||||
|
// fa.WithCookies(fa.Cookies{A: os.Getenv("FA_A"), B: os.Getenv("FA_B")}),
|
||||||
|
// fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
|
||||||
|
// fa.WithUserAgent(os.Getenv("FA_UA")),
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// sub, err := client.GetSubmission(ctx, 12345678)
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
// fmt.Println(sub.Title, "by", sub.Author.DisplayName)
|
||||||
|
//
|
||||||
|
// for sub, err := range client.Gallery(ctx, "someuser", fa.ListOptions{MaxPages: 3}) {
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
// fmt.Println(sub.ID, sub.Title)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// # Rate limiting
|
||||||
|
//
|
||||||
|
// All HTTP requests, including file downloads from the CDN, pass through a
|
||||||
|
// single token-bucket rate limiter. The default is one request per second,
|
||||||
|
// which is the lowest safe value for unauthenticated browsing of the site.
|
||||||
|
// Callers cannot bypass it; it lives inside the [http.RoundTripper].
|
||||||
|
//
|
||||||
|
// # Cloudflare
|
||||||
|
//
|
||||||
|
// FurAffinity sits behind Cloudflare. To pass challenge pages, callers must
|
||||||
|
// supply a fresh cf_clearance cookie obtained from a real browser, along
|
||||||
|
// with the exact User-Agent string that produced it. If the SDK receives a
|
||||||
|
// challenge response it returns [ErrCloudflareChallenge]; the caller should
|
||||||
|
// refresh the clearance cookie and retry.
|
||||||
|
package fa
|
||||||
556
e2e_test.go
Normal file
556
e2e_test.go
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rewritingRT routes every request to a fixed test server while preserving
|
||||||
|
// the original path and query. Lets us exercise the SDK end-to-end against
|
||||||
|
// canned responses without monkey-patching the urls package.
|
||||||
|
type rewritingRT struct {
|
||||||
|
target *url.URL
|
||||||
|
base http.RoundTripper
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rewritingRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
req2 := req.Clone(req.Context())
|
||||||
|
req2.URL = &url.URL{
|
||||||
|
Scheme: r.target.Scheme,
|
||||||
|
Host: r.target.Host,
|
||||||
|
Path: req.URL.Path,
|
||||||
|
RawQuery: req.URL.RawQuery,
|
||||||
|
}
|
||||||
|
req2.Host = r.target.Host
|
||||||
|
return r.base.RoundTrip(req2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newE2EClient(t *testing.T, srv *httptest.Server) *Client {
|
||||||
|
t.Helper()
|
||||||
|
target, err := url.Parse(srv.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse server url: %v", err)
|
||||||
|
}
|
||||||
|
hc := &http.Client{
|
||||||
|
Transport: &rewritingRT{target: target, base: http.DefaultTransport},
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
// Tight rate limit so tests don't sleep for seconds between pages.
|
||||||
|
return New(
|
||||||
|
WithHTTPClient(hc),
|
||||||
|
WithRateLimit(time.Microsecond, 16),
|
||||||
|
WithMaxRetries(0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2E_GetSubmission(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/view/1234/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = w.Write([]byte(syntheticSubmissionHTML))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
sub, err := client.GetSubmission(context.Background(), 1234)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSubmission: %v", err)
|
||||||
|
}
|
||||||
|
if sub.Title != "My Test Submission" {
|
||||||
|
t.Errorf("Title = %q", sub.Title)
|
||||||
|
}
|
||||||
|
if sub.Author.Name != "somefurry" {
|
||||||
|
t.Errorf("Author.Name = %q", sub.Author.Name)
|
||||||
|
}
|
||||||
|
if sub.Rating != RatingGeneral {
|
||||||
|
t.Errorf("Rating = %q", sub.Rating)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2E_GalleryIterator_WalksPages(t *testing.T) {
|
||||||
|
page1 := `<html><body>
|
||||||
|
<figure id="sid-1"><a href="/view/1/" title="One"><img src="/t1.png"/></a></figure>
|
||||||
|
<figure id="sid-2"><a href="/view/2/" title="Two"><img src="/t2.png"/></a></figure>
|
||||||
|
<a class="button standard" href="/gallery/me/2/">Next</a>
|
||||||
|
</body></html>`
|
||||||
|
page2 := `<html><body>
|
||||||
|
<figure id="sid-3"><a href="/view/3/" title="Three"><img src="/t3.png"/></a></figure>
|
||||||
|
</body></html>`
|
||||||
|
|
||||||
|
var hits atomic.Int32
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/gallery/me/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = w.Write([]byte(page1))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/gallery/me/2/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = w.Write([]byte(page2))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
var ids []SubmissionID
|
||||||
|
for sub, err := range client.Gallery(context.Background(), "me", ListOptions{}) {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("iter: %v", err)
|
||||||
|
}
|
||||||
|
ids = append(ids, sub.ID)
|
||||||
|
}
|
||||||
|
wantIDs := []SubmissionID{1, 2, 3}
|
||||||
|
if len(ids) != len(wantIDs) {
|
||||||
|
t.Fatalf("got %d ids; want %d", len(ids), len(wantIDs))
|
||||||
|
}
|
||||||
|
for i, id := range wantIDs {
|
||||||
|
if ids[i] != id {
|
||||||
|
t.Errorf("ids[%d] = %d; want %d", i, ids[i], id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hits.Load() != 2 {
|
||||||
|
t.Errorf("page fetches = %d; want 2", hits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2E_GalleryIterator_EarlyBreakStopsFetching(t *testing.T) {
|
||||||
|
page1 := `<html><body>
|
||||||
|
<figure id="sid-1"><a href="/view/1/" title="One"><img src="/t1.png"/></a></figure>
|
||||||
|
<figure id="sid-2"><a href="/view/2/" title="Two"><img src="/t2.png"/></a></figure>
|
||||||
|
<a class="button standard" href="/gallery/me/2/">Next</a>
|
||||||
|
</body></html>`
|
||||||
|
|
||||||
|
var hits atomic.Int32
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/gallery/me/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
_, _ = w.Write([]byte(page1))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/gallery/me/2/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
t.Error("page 2 should not have been fetched after early break")
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
for _, err := range client.Gallery(context.Background(), "me", ListOptions{}) {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("iter: %v", err)
|
||||||
|
}
|
||||||
|
break // stop after first
|
||||||
|
}
|
||||||
|
if hits.Load() != 1 {
|
||||||
|
t.Errorf("hits = %d; want 1", hits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2E_SystemMessage_NotFound(t *testing.T) {
|
||||||
|
notFoundHTML := `<html>
|
||||||
|
<head><title>System Error</title></head>
|
||||||
|
<body>
|
||||||
|
<section>
|
||||||
|
<div class="section-header"><h2>System Error</h2></div>
|
||||||
|
<div class="section-body">The submission you are trying to find is not in our database.</div>
|
||||||
|
</section>
|
||||||
|
</body></html>`
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/view/999/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(notFoundHTML))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
_, err := client.GetSubmission(context.Background(), 999)
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("got %v; want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2E_BrowseIterator_WalksPagesByFullness(t *testing.T) {
|
||||||
|
// Build a full page (browsePerPage items) so the iterator keeps going,
|
||||||
|
// then a half page (< browsePerPage) so it stops naturally.
|
||||||
|
fullPage := buildFigurePage(browsePerPage, 1)
|
||||||
|
tailPage := buildFigurePage(browsePerPage/2, 1+browsePerPage)
|
||||||
|
|
||||||
|
var hits atomic.Int32
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/browse/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
page := r.URL.Query().Get("page")
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
switch page {
|
||||||
|
case "", "1":
|
||||||
|
_, _ = w.Write([]byte(fullPage))
|
||||||
|
case "2":
|
||||||
|
_, _ = w.Write([]byte(tailPage))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected page=%q requested", page)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for sub, err := range client.Browse(context.Background(), BrowseOptions{}) {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("iter: %v", err)
|
||||||
|
}
|
||||||
|
if sub.ID == 0 {
|
||||||
|
t.Errorf("yielded submission with ID 0")
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
wantCount := browsePerPage + browsePerPage/2
|
||||||
|
if count != wantCount {
|
||||||
|
t.Errorf("yielded %d items; want %d", count, wantCount)
|
||||||
|
}
|
||||||
|
if hits.Load() != 2 {
|
||||||
|
t.Errorf("hit count = %d; want 2 (page=1 + page=2)", hits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2E_BrowseIterator_EarlyBreakStopsFetching(t *testing.T) {
|
||||||
|
fullPage := buildFigurePage(browsePerPage, 1)
|
||||||
|
|
||||||
|
var hits atomic.Int32
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/browse/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
_, _ = w.Write([]byte(fullPage))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
for _, err := range client.Browse(context.Background(), BrowseOptions{}) {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("iter: %v", err)
|
||||||
|
}
|
||||||
|
break // stop after the very first item
|
||||||
|
}
|
||||||
|
if hits.Load() != 1 {
|
||||||
|
t.Errorf("hit count = %d; want 1 (early break before page 2)", hits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2E_BrowseIterator_MaxPagesCap(t *testing.T) {
|
||||||
|
full := buildFigurePage(browsePerPage, 1)
|
||||||
|
|
||||||
|
var hits atomic.Int32
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/browse/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
_, _ = w.Write([]byte(full))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
count := 0
|
||||||
|
for _, err := range client.Browse(context.Background(), BrowseOptions{MaxPages: 2}) {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("iter: %v", err)
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if hits.Load() != 2 {
|
||||||
|
t.Errorf("hits = %d; want 2 (BrowseOptions.MaxPages=2 should cap fetches)", hits.Load())
|
||||||
|
}
|
||||||
|
if count != 2*browsePerPage {
|
||||||
|
t.Errorf("count = %d; want %d", count, 2*browsePerPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildFigurePage constructs a synthetic browse-style HTML page with n
|
||||||
|
// figure[id^=sid-] items, starting from idStart. Each figure mirrors the
|
||||||
|
// real FA structure closely enough to satisfy parseGalleryPage.
|
||||||
|
func buildFigurePage(n int, idStart int) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`<html><body>`)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
id := idStart + i
|
||||||
|
fmt.Fprintf(&b, `<figure id="sid-%d" class="r-general t-image u-artist%d">
|
||||||
|
<a href="/view/%d/" title="Submission %d"><img src="/t%d.png"/></a>
|
||||||
|
<figcaption>
|
||||||
|
<p><a href="/view/%d/" title="Submission %d">Submission %d</a></p>
|
||||||
|
<p><i>by</i> <a href="/user/artist%d/" title="Artist%d">Artist%d</a></p>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
`, id, i, id, id, id, id, id, id, i, i, i)
|
||||||
|
}
|
||||||
|
b.WriteString(`</body></html>`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2E_Search_FollowsNextPagination(t *testing.T) {
|
||||||
|
page1 := `<html><body>
|
||||||
|
<section id="gallery-search-results" class="gallery">
|
||||||
|
<figure id="sid-10"><a href="/view/10/" title="Ten"><img src="/t10.png"/></a>
|
||||||
|
<figcaption><p><a href="/view/10/" title="Ten">Ten</a></p><p>by <a href="/user/alice/">Alice</a></p></figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure id="sid-11"><a href="/view/11/" title="Eleven"><img src="/t11.png"/></a>
|
||||||
|
<figcaption><p><a href="/view/11/" title="Eleven">Eleven</a></p><p>by <a href="/user/bob/">Bob</a></p></figcaption>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
<div class="pagination">
|
||||||
|
<button class="button" disabled>Back</button>
|
||||||
|
<a class="button" href="/search/?q=x&page=2">Next</a>
|
||||||
|
</div>
|
||||||
|
</body></html>`
|
||||||
|
page2 := `<html><body>
|
||||||
|
<section id="gallery-search-results" class="gallery">
|
||||||
|
<figure id="sid-12"><a href="/view/12/" title="Twelve"><img src="/t12.png"/></a>
|
||||||
|
<figcaption><p><a href="/view/12/" title="Twelve">Twelve</a></p><p>by <a href="/user/carol/">Carol</a></p></figcaption>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
<div class="pagination">
|
||||||
|
<a class="button" href="/search/?q=x&page=1">Back</a>
|
||||||
|
</div>
|
||||||
|
</body></html>`
|
||||||
|
|
||||||
|
var hits atomic.Int32
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/search/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
page := r.URL.Query().Get("page")
|
||||||
|
if page == "2" {
|
||||||
|
_, _ = w.Write([]byte(page2))
|
||||||
|
} else {
|
||||||
|
_, _ = w.Write([]byte(page1))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
var ids []SubmissionID
|
||||||
|
for sub, err := range client.Search(context.Background(), "x", SearchOptions{}) {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("iter: %v", err)
|
||||||
|
}
|
||||||
|
ids = append(ids, sub.ID)
|
||||||
|
}
|
||||||
|
wantIDs := []SubmissionID{10, 11, 12}
|
||||||
|
if len(ids) != len(wantIDs) {
|
||||||
|
t.Fatalf("got %d ids; want %d (%v)", len(ids), len(wantIDs), ids)
|
||||||
|
}
|
||||||
|
for i, id := range wantIDs {
|
||||||
|
if ids[i] != id {
|
||||||
|
t.Errorf("ids[%d] = %d; want %d", i, ids[i], id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hits.Load() != 2 {
|
||||||
|
t.Errorf("hits = %d; want 2", hits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2E_Search_RespectsMaxPages(t *testing.T) {
|
||||||
|
full := `<html><body>
|
||||||
|
<section id="gallery-search-results">
|
||||||
|
<figure id="sid-1"><a href="/view/1/" title="A"><img src="/t.png"/></a>
|
||||||
|
<figcaption><p><a href="/view/1/" title="A">A</a></p><p>by <a href="/user/x/">X</a></p></figcaption>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
<div class="pagination"><a class="button" href="/search/?page=99">Next</a></div>
|
||||||
|
</body></html>`
|
||||||
|
|
||||||
|
var hits atomic.Int32
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/search/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
_, _ = w.Write([]byte(full))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
for _, err := range client.Search(context.Background(), "x", SearchOptions{MaxPages: 3}) {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("iter: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hits.Load() != 3 {
|
||||||
|
t.Errorf("hits = %d; want 3 (MaxPages cap)", hits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2E_SubmissionInbox_FollowsCursorPagination(t *testing.T) {
|
||||||
|
// Build a fake inbox page with the date-divider wrapper and a cursor
|
||||||
|
// link that points at a second page; the second page omits the cursor
|
||||||
|
// so the iterator stops naturally.
|
||||||
|
page1 := `<html><body>
|
||||||
|
<div id="messagecenter-submissions">
|
||||||
|
<div class="notifications-by-date" data-date="1779177165">
|
||||||
|
<h4>Today</h4>
|
||||||
|
<section class="gallery">
|
||||||
|
<figure id="sid-1"><a href="/view/1/" title="One"><img src="/t1.png"/></a>
|
||||||
|
<figcaption><p><a href="/view/1/" title="One">One</a></p><p>by <a href="/user/alice/">Alice</a></p></figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure id="sid-2"><a href="/view/2/" title="Two"><img src="/t2.png"/></a>
|
||||||
|
<figcaption><p><a href="/view/2/" title="Two">Two</a></p><p>by <a href="/user/bob/">Bob</a></p></figcaption>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="messagecenter-navigation">
|
||||||
|
<a class="button standard more" href="/msg/submissions/new~2@72/">Next 72</a>
|
||||||
|
</div>
|
||||||
|
</body></html>`
|
||||||
|
page2 := `<html><body>
|
||||||
|
<div id="messagecenter-submissions">
|
||||||
|
<div class="notifications-by-date" data-date="1779000000">
|
||||||
|
<h4>Yesterday</h4>
|
||||||
|
<section class="gallery">
|
||||||
|
<figure id="sid-3"><a href="/view/3/" title="Three"><img src="/t3.png"/></a>
|
||||||
|
<figcaption><p><a href="/view/3/" title="Three">Three</a></p><p>by <a href="/user/carol/">Carol</a></p></figcaption>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>`
|
||||||
|
|
||||||
|
var hits atomic.Int32
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/msg/submissions/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
_, _ = w.Write([]byte(page1))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/msg/submissions/new~2@72/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
_, _ = w.Write([]byte(page2))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
var ids []SubmissionID
|
||||||
|
var posted []time.Time
|
||||||
|
for sub, err := range client.SubmissionInbox(context.Background(), ListOptions{}) {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("iter: %v", err)
|
||||||
|
}
|
||||||
|
ids = append(ids, sub.ID)
|
||||||
|
posted = append(posted, sub.PostedAt)
|
||||||
|
}
|
||||||
|
wantIDs := []SubmissionID{1, 2, 3}
|
||||||
|
if len(ids) != len(wantIDs) {
|
||||||
|
t.Fatalf("got %d ids; want %d", len(ids), len(wantIDs))
|
||||||
|
}
|
||||||
|
for i, id := range wantIDs {
|
||||||
|
if ids[i] != id {
|
||||||
|
t.Errorf("ids[%d] = %d; want %d", i, ids[i], id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if posted[0].IsZero() || posted[2].IsZero() {
|
||||||
|
t.Errorf("PostedAt not populated from data-date: %v / %v", posted[0], posted[2])
|
||||||
|
}
|
||||||
|
if hits.Load() != 2 {
|
||||||
|
t.Errorf("hits = %d; want 2", hits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// inboxPageHTML builds a /msg/submissions/ page wrapping figures for the
|
||||||
|
// given submission IDs (newest first) in FA's date-divider markup.
|
||||||
|
func inboxPageHTML(ids []int) string {
|
||||||
|
var figs strings.Builder
|
||||||
|
for _, id := range ids {
|
||||||
|
fmt.Fprintf(&figs,
|
||||||
|
`<figure id="sid-%d"><a href="/view/%d/" title="S%d"><img src="/t.png"/></a></figure>`,
|
||||||
|
id, id, id)
|
||||||
|
}
|
||||||
|
return `<html><body><div id="messagecenter-submissions">` +
|
||||||
|
`<div class="notifications-by-date" data-date="1779177165">` +
|
||||||
|
`<section class="gallery">` + figs.String() + `</section></div></div></body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// When FA serves a full inbox page (72 items) but omits the "Next 72"
|
||||||
|
// cursor link, the iterator must keep crawling by synthesizing the cursor
|
||||||
|
// from the oldest submission on the page otherwise a multi-thousand-item
|
||||||
|
// inbox is truncated to its first page (SDK issue #23).
|
||||||
|
func TestE2E_SubmissionInbox_SynthesizesCursorWhenLinkMissing(t *testing.T) {
|
||||||
|
// Page 1: a full page (72 items, ids 200..129) with NO "Next 72" link.
|
||||||
|
page1IDs := make([]int, 0, 72)
|
||||||
|
for id := 200; id >= 129; id-- {
|
||||||
|
page1IDs = append(page1IDs, id)
|
||||||
|
}
|
||||||
|
// Page 2: the synthesized cursor lands here; a short final page.
|
||||||
|
page2IDs := make([]int, 0, 30)
|
||||||
|
for id := 128; id >= 99; id-- {
|
||||||
|
page2IDs = append(page2IDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/msg/submissions/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(inboxPageHTML(page1IDs)))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/msg/submissions/new~129@72/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(inboxPageHTML(page2IDs)))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
var got []int
|
||||||
|
for sub, err := range client.SubmissionInbox(context.Background(), ListOptions{}) {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("iter: %v", err)
|
||||||
|
}
|
||||||
|
got = append(got, int(sub.ID))
|
||||||
|
}
|
||||||
|
if len(got) != 102 {
|
||||||
|
t.Fatalf("got %d items; want 102 (72 + 30)", len(got))
|
||||||
|
}
|
||||||
|
if got[0] != 200 || got[101] != 99 {
|
||||||
|
t.Errorf("boundary ids = %d..%d; want 200..99", got[0], got[101])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2E_ContextCancelInterruptsIterator(t *testing.T) {
|
||||||
|
page := `<html><body>
|
||||||
|
<figure id="sid-1"><a href="/view/1/" title="One"><img src="/t1.png"/></a></figure>
|
||||||
|
<a class="button standard" href="/gallery/me/2/">Next</a>
|
||||||
|
</body></html>`
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/gallery/me/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(page))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/gallery/me/2/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
<-r.Context().Done()
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
gotErr := error(nil)
|
||||||
|
count := 0
|
||||||
|
go func() {
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
for _, err := range client.Gallery(ctx, "me", ListOptions{}) {
|
||||||
|
if err != nil {
|
||||||
|
gotErr = err
|
||||||
|
break
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if gotErr == nil {
|
||||||
|
t.Fatal("expected cancellation error; iterator completed normally")
|
||||||
|
}
|
||||||
|
}
|
||||||
46
enums.go
Normal file
46
enums.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Rating reflects the submission's content rating as displayed on the page.
|
||||||
|
// FA uses three buckets. Unknown values from future site changes are preserved
|
||||||
|
// verbatim rather than mapped to an "Unknown" constant, so parser drift
|
||||||
|
// surfaces in field values, not in panics.
|
||||||
|
type Rating string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RatingGeneral Rating = "General"
|
||||||
|
RatingMature Rating = "Mature"
|
||||||
|
RatingAdult Rating = "Adult"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseRating normalises a rating string (case-insensitive) into one of the
|
||||||
|
// known constants, or returns it lower-cased-and-titled verbatim if unknown.
|
||||||
|
func ParseRating(s string) Rating {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||||
|
case "general", "g":
|
||||||
|
return RatingGeneral
|
||||||
|
case "mature", "m":
|
||||||
|
return RatingMature
|
||||||
|
case "adult", "a", "explicit", "e":
|
||||||
|
return RatingAdult
|
||||||
|
default:
|
||||||
|
return Rating(strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category is FA's "Category" metadata field (e.g. "Artwork (Digital)",
|
||||||
|
// "Story", "Music"). Stored as the raw string so unknown categories survive.
|
||||||
|
type Category string
|
||||||
|
|
||||||
|
// Type is FA's "Type" metadata field. Often parallels Category for visual
|
||||||
|
// art ("Digital", "Traditional", "Photography", ...) and differs for other
|
||||||
|
// media. Stored raw.
|
||||||
|
type Type string
|
||||||
|
|
||||||
|
// Species is FA's "Species" metadata field (e.g. "Wolf", "Dragon", "Fox").
|
||||||
|
// Free-form on the site.
|
||||||
|
type Species string
|
||||||
|
|
||||||
|
// Gender is FA's "Gender" metadata field (e.g. "Male", "Female", "Any").
|
||||||
|
type Gender string
|
||||||
24
enums_test.go
Normal file
24
enums_test.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseRating(t *testing.T) {
|
||||||
|
cases := map[string]Rating{
|
||||||
|
"General": RatingGeneral,
|
||||||
|
"general": RatingGeneral,
|
||||||
|
"g": RatingGeneral,
|
||||||
|
" Mature": RatingMature,
|
||||||
|
"M": RatingMature,
|
||||||
|
"Adult": RatingAdult,
|
||||||
|
"a": RatingAdult,
|
||||||
|
"Explicit": RatingAdult,
|
||||||
|
"E": RatingAdult,
|
||||||
|
// Unknown values survive verbatim (trimmed).
|
||||||
|
"WhoKnows": Rating("WhoKnows"),
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := ParseRating(in); got != want {
|
||||||
|
t.Errorf("ParseRating(%q) = %q; want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
errors.go
Normal file
65
errors.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel errors. Callers should use errors.Is to test for these.
|
||||||
|
var (
|
||||||
|
// ErrNotFound is returned when FA renders a "not found" system message
|
||||||
|
// or an HTTP 404. Covers deleted submissions, unknown users, etc.
|
||||||
|
ErrNotFound = errors.New("fa: not found")
|
||||||
|
|
||||||
|
// ErrUnauthorized is returned when an endpoint requires authentication
|
||||||
|
// (the SDK was called without cookies, or the cookies were rejected by FA).
|
||||||
|
ErrUnauthorized = errors.New("fa: unauthorized cookies missing or invalid")
|
||||||
|
|
||||||
|
// ErrCloudflareChallenge is returned when Cloudflare interposes a JS
|
||||||
|
// challenge or managed challenge. Callers must refresh their cf_clearance
|
||||||
|
// cookie and User-Agent in a real browser, then retry.
|
||||||
|
ErrCloudflareChallenge = errors.New("fa: cloudflare challenge refresh cf_clearance")
|
||||||
|
|
||||||
|
// ErrRateLimited is returned when FA or Cloudflare responds with HTTP 429
|
||||||
|
// and the transport's retry budget has been exhausted.
|
||||||
|
ErrRateLimited = errors.New("fa: rate limited by server")
|
||||||
|
|
||||||
|
// ErrSystemMessage is the umbrella error for any FA "system-message" page
|
||||||
|
// that doesn't match a more specific sentinel. Wrap in a *SystemMessageError
|
||||||
|
// for the title/body.
|
||||||
|
ErrSystemMessage = errors.New("fa: system message")
|
||||||
|
|
||||||
|
// ErrParse is returned when the HTML parser cannot extract a required field.
|
||||||
|
// Often indicates FA changed its markup.
|
||||||
|
ErrParse = errors.New("fa: parse error")
|
||||||
|
)
|
||||||
|
|
||||||
|
// SystemMessageError carries the title and body of a FA system-message page
|
||||||
|
// that doesn't classify into one of the more specific sentinels.
|
||||||
|
type SystemMessageError struct {
|
||||||
|
Title string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *SystemMessageError) Error() string {
|
||||||
|
if e.Title == "" {
|
||||||
|
return "fa: system message: " + e.Body
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("fa: system message: %s: %s", e.Title, e.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is allows errors.Is(err, ErrSystemMessage) to match.
|
||||||
|
func (e *SystemMessageError) Is(target error) bool {
|
||||||
|
return target == ErrSystemMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPError wraps a non-success HTTP response that the transport surfaces
|
||||||
|
// to the caller after exhausting retries.
|
||||||
|
type HTTPError struct {
|
||||||
|
StatusCode int
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HTTPError) Error() string {
|
||||||
|
return fmt.Sprintf("fa: http %d for %s", e.StatusCode, e.URL)
|
||||||
|
}
|
||||||
34
errors_test.go
Normal file
34
errors_test.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSystemMessageError_IsErrSystemMessage(t *testing.T) {
|
||||||
|
err := &SystemMessageError{Title: "Something", Body: "broke"}
|
||||||
|
if !errors.Is(err, ErrSystemMessage) {
|
||||||
|
t.Fatalf("errors.Is(SystemMessageError, ErrSystemMessage) = false; want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPError_Format(t *testing.T) {
|
||||||
|
e := &HTTPError{StatusCode: 503, URL: "https://example/"}
|
||||||
|
if got := e.Error(); got != "fa: http 503 for https://example/" {
|
||||||
|
t.Fatalf("HTTPError.Error() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSentinelsAreDistinct(t *testing.T) {
|
||||||
|
sentinels := []error{
|
||||||
|
ErrNotFound, ErrUnauthorized, ErrCloudflareChallenge,
|
||||||
|
ErrRateLimited, ErrSystemMessage, ErrParse,
|
||||||
|
}
|
||||||
|
for i, a := range sentinels {
|
||||||
|
for j, b := range sentinels {
|
||||||
|
if i != j && errors.Is(a, b) {
|
||||||
|
t.Errorf("sentinel %v wrongly matches %v", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
examples/basic/main.go
Normal file
55
examples/basic/main.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// basic demonstrates use of the SDK: fetching a single submission and
|
||||||
|
// printing a few fields.
|
||||||
|
//
|
||||||
|
// The example runs anonymously by default. If the FA_A / FA_B (and ideally
|
||||||
|
// CF_CLEARANCE + FA_UA) environment variables are set, it authenticates
|
||||||
|
// with them required for any submission FA gates behind login, mature
|
||||||
|
// content guard, or Cloudflare challenges.
|
||||||
|
//
|
||||||
|
// go run ./examples/basic 12345678
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
fa "git.anthrove.art/public/go-fa-api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
log.Fatalf("usage: %s <submission-id>", os.Args[0])
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(os.Args[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid submission id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := []fa.Option{fa.WithUserAgent("go-fa-api-example/0.1")}
|
||||||
|
if a, b := os.Getenv("FA_A"), os.Getenv("FA_B"); a != "" && b != "" {
|
||||||
|
log.Printf("using FA_A/FA_B cookies for authenticated request")
|
||||||
|
opts = []fa.Option{
|
||||||
|
fa.WithCookies(fa.Cookies{A: a, B: b}),
|
||||||
|
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
|
||||||
|
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client := fa.New(opts...)
|
||||||
|
|
||||||
|
sub, err := client.GetSubmission(context.Background(), fa.SubmissionID(id))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("GetSubmission: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("%s\nby %s\nrating: %s\ntags: %v\nfile: %s\n",
|
||||||
|
sub.Title, sub.Author.DisplayName, sub.Rating, sub.Tags, sub.FileURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
54
examples/browse/main.go
Normal file
54
examples/browse/main.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// browse prints the global front-page feed at /browse/ FA's "what's new
|
||||||
|
// across the site" stream. Uses anon access by default; honours FA_A /
|
||||||
|
// FA_B / CF_CLEARANCE / FA_UA from env when set (recommended, since FA
|
||||||
|
// gates parts of the feed behind login + Cloudflare).
|
||||||
|
//
|
||||||
|
// go run ./examples/browse # 1 page, ~72 items
|
||||||
|
// go run ./examples/browse 3 # 3 pages
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
fa "git.anthrove.art/public/go-fa-api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
maxPages := 1
|
||||||
|
if len(os.Args) >= 2 {
|
||||||
|
if n, err := strconv.Atoi(os.Args[1]); err == nil && n > 0 {
|
||||||
|
maxPages = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := []fa.Option{fa.WithUserAgent("go-fa-api-example/0.1")}
|
||||||
|
if a, b := os.Getenv("FA_A"), os.Getenv("FA_B"); a != "" && b != "" {
|
||||||
|
opts = []fa.Option{
|
||||||
|
fa.WithCookies(fa.Cookies{A: a, B: b}),
|
||||||
|
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
|
||||||
|
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client := fa.New(opts...)
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for sub, err := range client.Browse(context.Background(), fa.BrowseOptions{MaxPages: maxPages}) {
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Browse: %v", err)
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
fmt.Printf("[%d] %s %s by %s\n", sub.ID, sub.Title, sub.Rating, sub.Author.DisplayName)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n%d submissions across %d page(s)\n", count, maxPages)
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
49
examples/download/main.go
Normal file
49
examples/download/main.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// download fetches a single submission and streams its main file to disk.
|
||||||
|
// Demonstrates that downloads share the SDK's rate limiter and cookie jar
|
||||||
|
// with metadata fetches.
|
||||||
|
//
|
||||||
|
// go run ./examples/download 12345678 out.jpg
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
fa "git.anthrove.art/public/go-fa-api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
log.Fatalf("usage: %s <submission-id> <out-path>", os.Args[0])
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(os.Args[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := fa.New(
|
||||||
|
fa.WithCookies(fa.Cookies{A: os.Getenv("FA_A"), B: os.Getenv("FA_B")}),
|
||||||
|
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
|
||||||
|
fa.WithUserAgent(os.Getenv("FA_UA")),
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
sub, err := client.GetSubmission(ctx, fa.SubmissionID(id))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("GetSubmission: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(os.Args[2])
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
n, err := client.Download(ctx, sub, out)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("download: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("wrote %d bytes to %s", n, os.Args[2])
|
||||||
|
}
|
||||||
54
examples/gallery_dump/main.go
Normal file
54
examples/gallery_dump/main.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// gallery_dump iterates a user's gallery (authenticated) and prints the
|
||||||
|
// title and ID of every submission encountered, honouring the SDK's default
|
||||||
|
// 1 req/sec rate limit.
|
||||||
|
//
|
||||||
|
// Required environment variables:
|
||||||
|
//
|
||||||
|
// FA_A — the `a` session cookie
|
||||||
|
// FA_B — the `b` session cookie
|
||||||
|
// CF_CLEARANCE — the cf_clearance cookie from the same browser session
|
||||||
|
// FA_UA — the User-Agent string that produced CF_CLEARANCE
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// go run ./examples/gallery_dump <username> [maxPages]
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
fa "git.anthrove.art/public/go-fa-api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
log.Fatalf("usage: %s <username> [maxPages]", os.Args[0])
|
||||||
|
}
|
||||||
|
user := os.Args[1]
|
||||||
|
maxPages := 0
|
||||||
|
if len(os.Args) >= 3 {
|
||||||
|
if n, err := strconv.Atoi(os.Args[2]); err == nil {
|
||||||
|
maxPages = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := fa.New(
|
||||||
|
fa.WithCookies(fa.Cookies{A: os.Getenv("FA_A"), B: os.Getenv("FA_B")}),
|
||||||
|
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
|
||||||
|
fa.WithUserAgent(os.Getenv("FA_UA")),
|
||||||
|
)
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for sub, err := range client.Gallery(context.Background(), user, fa.ListOptions{MaxPages: maxPages}) {
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("iter: %v", err)
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
fmt.Printf("[%d] %s %s\n", sub.ID, sub.Title, sub.Rating)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n%d submissions\n", count)
|
||||||
|
}
|
||||||
76
examples/inbox/main.go
Normal file
76
examples/inbox/main.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// inbox prints the logged-in user's new submissions the "what's new
|
||||||
|
// from people you watch" feed at https://www.furaffinity.net/msg/submissions/.
|
||||||
|
//
|
||||||
|
// Requires FA_A and FA_B (session cookies). CF_CLEARANCE + FA_UA are
|
||||||
|
// strongly recommended without them FurAffinity's Cloudflare layer will
|
||||||
|
// usually serve a challenge page instead of the inbox.
|
||||||
|
//
|
||||||
|
// go run ./examples/inbox # first page (~72 items)
|
||||||
|
// go run ./examples/inbox 3 # up to 3 cursor pages (~216 items)
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
fa "git.anthrove.art/public/go-fa-api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
maxPages := 1
|
||||||
|
if len(os.Args) >= 2 {
|
||||||
|
if n, err := strconv.Atoi(os.Args[1]); err == nil && n > 0 {
|
||||||
|
maxPages = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a, b := os.Getenv("FA_A"), os.Getenv("FA_B")
|
||||||
|
if a == "" || b == "" {
|
||||||
|
log.Fatal("FA_A and FA_B must be set the submission inbox requires login")
|
||||||
|
}
|
||||||
|
//if os.Getenv("CF_CLEARANCE") == "" || os.Getenv("FA_UA") == "" {
|
||||||
|
// log.Println("warning: CF_CLEARANCE / FA_UA not set expect a Cloudflare challenge")
|
||||||
|
//}
|
||||||
|
|
||||||
|
client := fa.New(
|
||||||
|
fa.WithCookies(fa.Cookies{A: a, B: b}),
|
||||||
|
//fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
|
||||||
|
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
|
||||||
|
// JSON-first listing parse: more resilient to FA markup tweaks,
|
||||||
|
// and populates each item's author avatar URL.
|
||||||
|
fa.WithExperimentalJSONListings(false),
|
||||||
|
)
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for sub, err := range client.SubmissionInbox(context.Background(), fa.ListOptions{MaxPages: maxPages}) {
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, fa.ErrCloudflareChallenge):
|
||||||
|
log.Fatal("Cloudflare challenge refresh CF_CLEARANCE + FA_UA from your browser and retry")
|
||||||
|
case errors.Is(err, fa.ErrUnauthorized):
|
||||||
|
log.Fatal("unauthorized FA_A / FA_B are missing or expired")
|
||||||
|
default:
|
||||||
|
log.Fatalf("SubmissionInbox: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
when := "—"
|
||||||
|
if !sub.PostedAt.IsZero() {
|
||||||
|
when = sub.PostedAt.Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
fmt.Printf("[%d] %-50.50s %-8s by %s (%s)\n",
|
||||||
|
sub.ID, sub.Title, sub.Rating, sub.Author.DisplayName, when)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n%d new submission(s) across up to %d page(s)\n", count, maxPages)
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
73
examples/notes/main.go
Normal file
73
examples/notes/main.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// notes dumps the /msg/pms/ inbox listing and, if an argument is given,
|
||||||
|
// prints the full body of that note id. Requires FA_A / FA_B in env.
|
||||||
|
//
|
||||||
|
// go run ./examples/notes # list inbox
|
||||||
|
// go run ./examples/notes 131012623 # read one note
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
fa "git.anthrove.art/public/go-fa-api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
a, b := os.Getenv("FA_A"), os.Getenv("FA_B")
|
||||||
|
if a == "" || b == "" {
|
||||||
|
log.Fatal("FA_A and FA_B must be set notes require login")
|
||||||
|
}
|
||||||
|
client := fa.New(
|
||||||
|
fa.WithCookies(fa.Cookies{A: a, B: b}),
|
||||||
|
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
|
||||||
|
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
|
||||||
|
)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if len(os.Args) >= 2 {
|
||||||
|
id, err := strconv.ParseInt(os.Args[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid note id: %v", err)
|
||||||
|
}
|
||||||
|
n, err := client.GetNote(ctx, fa.NoteID(id))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("GetNote: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Subject: %s\nFrom: %s (@%s)\nTo: %s (@%s)\nSent: %s\n\n%s\n",
|
||||||
|
n.Subject,
|
||||||
|
n.From.DisplayName, n.From.Name,
|
||||||
|
n.To.DisplayName, n.To.Name,
|
||||||
|
n.SentAt.Format("2006-01-02 15:04"),
|
||||||
|
n.BodyText)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for np, err := range client.Notes(ctx, fa.ListOptions{MaxPages: 1}) {
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Notes: %v", err)
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
unread := " "
|
||||||
|
if np.Unread {
|
||||||
|
unread = "*"
|
||||||
|
}
|
||||||
|
from := np.Sender.DisplayName
|
||||||
|
if from == "" {
|
||||||
|
from = np.Sender.Name
|
||||||
|
}
|
||||||
|
fmt.Printf("[%s] [%d] %s from %s (%s)\n",
|
||||||
|
unread, np.ID, np.Subject, from, np.SentAt.Format("2006-01-02 15:04"))
|
||||||
|
}
|
||||||
|
fmt.Printf("\n%d notes on first page\n", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
70
examples/notifications/main.go
Normal file
70
examples/notifications/main.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// notifications dumps the full /msg/others/ page watch / journal /
|
||||||
|
// comment / fav / shout notifications, single fetch, all categories.
|
||||||
|
// Requires FA_A / FA_B in env (and ideally CF_CLEARANCE + FA_UA).
|
||||||
|
//
|
||||||
|
// go run ./examples/notifications
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
fa "git.anthrove.art/public/go-fa-api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
a, b := os.Getenv("FA_A"), os.Getenv("FA_B")
|
||||||
|
if a == "" || b == "" {
|
||||||
|
log.Fatal("FA_A and FA_B must be set notifications require login")
|
||||||
|
}
|
||||||
|
client := fa.New(
|
||||||
|
fa.WithCookies(fa.Cookies{A: a, B: b}),
|
||||||
|
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
|
||||||
|
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
|
||||||
|
)
|
||||||
|
|
||||||
|
n, err := client.Notifications(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Notifications: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
section("Journals", len(n.Journals))
|
||||||
|
for _, j := range n.Journals {
|
||||||
|
fmt.Printf(" [%d] %s by %s (%s, %s)\n",
|
||||||
|
j.JournalID, j.Title, j.Author.DisplayName, j.Rating,
|
||||||
|
j.PostedAt.Format("2006-01-02 15:04"))
|
||||||
|
}
|
||||||
|
section("New watchers", len(n.Watches))
|
||||||
|
for _, w := range n.Watches {
|
||||||
|
fmt.Printf(" %s (%s)\n", w.User.DisplayName, w.WatchedAt.Format("2006-01-02 15:04"))
|
||||||
|
}
|
||||||
|
section("Submission comments", len(n.SubmissionComments))
|
||||||
|
for _, c := range n.SubmissionComments {
|
||||||
|
fmt.Printf(" on submission %d (%q) by %s\n", c.OnSubmission, c.OnTitle, c.Author.DisplayName)
|
||||||
|
}
|
||||||
|
section("Journal comments", len(n.JournalComments))
|
||||||
|
for _, c := range n.JournalComments {
|
||||||
|
fmt.Printf(" on journal %d (%q) by %s\n", c.OnJournal, c.OnTitle, c.Author.DisplayName)
|
||||||
|
}
|
||||||
|
section("Favorites", len(n.Favorites))
|
||||||
|
for _, f := range n.Favorites {
|
||||||
|
fmt.Printf(" %s favorited %d (%q)\n", f.Favoriter.DisplayName, f.SubmissionID, f.SubmissionTitle)
|
||||||
|
}
|
||||||
|
section("Shouts", len(n.Shouts))
|
||||||
|
for _, s := range n.Shouts {
|
||||||
|
fmt.Printf(" shout from %s (%s)\n", s.Author.DisplayName, s.PostedAt.Format("2006-01-02 15:04"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func section(name string, count int) {
|
||||||
|
fmt.Printf("\n=== %s (%d) ===\n", name, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
117
examples/priority/main.go
Normal file
117
examples/priority/main.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// priority demonstrates multi-level rate-limiter priority.
|
||||||
|
//
|
||||||
|
// A background gallery crawl runs continuously at fa.PriorityBackground
|
||||||
|
// while interactive submission fetches at fa.PriorityInteractive jump ahead
|
||||||
|
// of it even though the crawl started first and both share one global
|
||||||
|
// token bucket. (fa.PriorityNormal and fa.PriorityLow sit between the two;
|
||||||
|
// the same rule applies higher priority is always served first.)
|
||||||
|
//
|
||||||
|
// The example runs anonymously by default. If the FA_A / FA_B (and ideally
|
||||||
|
// CF_CLEARANCE + FA_UA) environment variables are set, it authenticates
|
||||||
|
// with them.
|
||||||
|
//
|
||||||
|
// Watch the timestamps in the output: once the interactive fetches are
|
||||||
|
// issued, they take the next tokens and the background crawl is pushed back.
|
||||||
|
//
|
||||||
|
// go run ./examples/priority <username> <submission-id> [<submission-id>...]
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
fa "git.anthrove.art/public/go-fa-api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
log.Fatalf("usage: %s <username> <submission-id> [<submission-id>...]", os.Args[0])
|
||||||
|
}
|
||||||
|
user := os.Args[1]
|
||||||
|
ids := make([]fa.SubmissionID, 0, len(os.Args)-2)
|
||||||
|
for _, arg := range os.Args[2:] {
|
||||||
|
n, err := strconv.ParseInt(arg, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid submission id %q: %v", arg, err)
|
||||||
|
}
|
||||||
|
ids = append(ids, fa.SubmissionID(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority scheduling must be enabled at construction time; without
|
||||||
|
// WithPrioritizedRateLimiting the WithPriority markers below are inert.
|
||||||
|
opts := []fa.Option{
|
||||||
|
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
|
||||||
|
fa.WithPrioritizedRateLimiting(true),
|
||||||
|
}
|
||||||
|
if a, b := os.Getenv("FA_A"), os.Getenv("FA_B"); a != "" && b != "" {
|
||||||
|
log.Printf("using FA_A/FA_B cookies for authenticated requests")
|
||||||
|
opts = append(opts,
|
||||||
|
fa.WithCookies(fa.Cookies{A: a, B: b}),
|
||||||
|
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
client := fa.New(opts...)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
since := func() string { return fmt.Sprintf("%5.1fs", time.Since(start).Seconds()) }
|
||||||
|
|
||||||
|
// Background crawler: walks the user's gallery at the lowest priority,
|
||||||
|
// keeping the shared token bucket busy so the priority effect is visible.
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
bgCtx := fa.WithBackgroundPriority(ctx)
|
||||||
|
n := 0
|
||||||
|
for sub, err := range client.Gallery(bgCtx, user, fa.ListOptions{}) {
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() == nil { // not just our own cancel
|
||||||
|
log.Printf("[%s] background crawl stopped: %v", since(), err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
fmt.Printf("[%s] background · gallery item %3d (%d) %s\n",
|
||||||
|
since(), n, sub.ID, sub.Title)
|
||||||
|
}
|
||||||
|
fmt.Printf("[%s] background · gallery exhausted after %d items\n", since(), n)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Give the crawler a head start so it is mid-crawl, holding the queue,
|
||||||
|
// when the interactive fetches arrive.
|
||||||
|
time.Sleep(1500 * time.Millisecond)
|
||||||
|
|
||||||
|
// Interactive fetches: the user is waiting on these. Each takes the very
|
||||||
|
// next token, ahead of the background crawl's pending page fetch.
|
||||||
|
for _, id := range ids {
|
||||||
|
ictx := fa.WithPriority(ctx, fa.PriorityInteractive)
|
||||||
|
t0 := time.Now()
|
||||||
|
sub, err := client.GetSubmission(ictx, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[%s] interactive · GetSubmission(%d): %v", since(), id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf("[%s] INTERACTIVE · got (%d) %q in %.1fs jumped the queue\n",
|
||||||
|
since(), id, sub.Title, time.Since(t0).Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Foreground work is done stop the background crawler and wait for it.
|
||||||
|
cancel()
|
||||||
|
wg.Wait()
|
||||||
|
fmt.Printf("[%s] done\n", since())
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
68
examples/search/main.go
Normal file
68
examples/search/main.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// search runs a /search/?q=... query and prints the first N pages of
|
||||||
|
// matches. Works anonymously for general-rated results; mature/adult
|
||||||
|
// searches require login.
|
||||||
|
//
|
||||||
|
// go run ./examples/search dragon
|
||||||
|
// go run ./examples/search "fox knight" --max=2 --rating=general --order=date
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
fa "git.anthrove.art/public/go-fa-api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
maxPages := flag.Int("max", 1, "max pages to fetch")
|
||||||
|
ratingFlag := flag.String("rating", "", "comma-separated: general, mature, adult (empty = all)")
|
||||||
|
orderFlag := flag.String("order", "", "relevancy | date | popularity (empty = default)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if flag.NArg() == 0 {
|
||||||
|
log.Fatalf("usage: %s [flags] <query>", os.Args[0])
|
||||||
|
}
|
||||||
|
query := strings.Join(flag.Args(), " ")
|
||||||
|
|
||||||
|
opts := []fa.Option{fa.WithUserAgent("go-fa-api-example/0.1")}
|
||||||
|
if a, b := os.Getenv("FA_A"), os.Getenv("FA_B"); a != "" && b != "" {
|
||||||
|
opts = []fa.Option{
|
||||||
|
fa.WithCookies(fa.Cookies{A: a, B: b}),
|
||||||
|
fa.WithCloudflare(fa.CFCookies{Clearance: os.Getenv("CF_CLEARANCE")}),
|
||||||
|
fa.WithUserAgent(envOr("FA_UA", "go-fa-api-example/0.1")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client := fa.New(opts...)
|
||||||
|
|
||||||
|
so := fa.SearchOptions{MaxPages: *maxPages}
|
||||||
|
if *ratingFlag != "" {
|
||||||
|
for _, r := range strings.Split(*ratingFlag, ",") {
|
||||||
|
so.Ratings = append(so.Ratings, fa.ParseRating(strings.TrimSpace(r)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *orderFlag != "" {
|
||||||
|
so.OrderBy = fa.SearchOrder(*orderFlag)
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for sub, err := range client.Search(context.Background(), query, so) {
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
fmt.Printf("[%d] %s %s by %s\n",
|
||||||
|
sub.ID, sub.Title, sub.Rating, sub.Author.DisplayName)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n%d results for %q across up to %d page(s)\n", count, query, *maxPages)
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
330
fixtures_refresh_test.go
Normal file
330
fixtures_refresh_test.go
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
//go:build fixtures
|
||||||
|
|
||||||
|
// This file is compiled only when the `fixtures` build tag is set:
|
||||||
|
//
|
||||||
|
// go test -tags=fixtures -run TestRefreshFixtures ./...
|
||||||
|
//
|
||||||
|
// It hits live FurAffinity with the cookies in your environment and snapshots
|
||||||
|
// the response body of each curated page into testdata/html/. The regular
|
||||||
|
// parser tests read from those snapshots, so this is how we keep the parser
|
||||||
|
// in sync with the live site without baking sample data into the repo.
|
||||||
|
//
|
||||||
|
// Each fixture is its own subtest. A failure on one (network blip, dead
|
||||||
|
// target, fresh CF challenge) does not abort the rest.
|
||||||
|
//
|
||||||
|
// # Required environment variables
|
||||||
|
//
|
||||||
|
// FA_A — `a` session cookie
|
||||||
|
// FA_B — `b` session cookie
|
||||||
|
// CF_CLEARANCE — cf_clearance cookie from the same browser session
|
||||||
|
// FA_UA — User-Agent string that produced CF_CLEARANCE
|
||||||
|
//
|
||||||
|
// # Per-fixture targets
|
||||||
|
//
|
||||||
|
// All of these have defaults that fall back to FA_TEST_USER (your own login
|
||||||
|
// name) where possible. Set them explicitly to capture data from somewhere
|
||||||
|
// other than your own profile.
|
||||||
|
//
|
||||||
|
// FA_TEST_USER base username (yours)
|
||||||
|
// FA_TEST_SUB_ID image submission ID (default: 12345678)
|
||||||
|
// FA_TEST_SUB_STORY_ID non-image submission ID (story/music/PDF)
|
||||||
|
// FA_TEST_GALLERY_USER gallery owner (default: FA_TEST_USER)
|
||||||
|
// FA_TEST_GALLERY_LAST_PAGE page index near/at the end of that gallery
|
||||||
|
// FA_TEST_SCRAPS_USER scraps owner (default: FA_TEST_GALLERY_USER)
|
||||||
|
// FA_TEST_FAVORITES_USER favorites owner (default: FA_TEST_USER)
|
||||||
|
// FA_TEST_JOURNALS_USER journals listing owner (default: FA_TEST_USER)
|
||||||
|
// FA_TEST_JOURNAL_ID single journal ID
|
||||||
|
// FA_TEST_USER_WITH_SHOUTS profile that has visible shouts
|
||||||
|
// FA_TEST_USER_WITH_BANNER profile that has a custom site banner uploaded
|
||||||
|
// FA_TEST_NOTE_ID single note (PM) ID (M2 prep)
|
||||||
|
// FA_TEST_SEARCH_QUERY search keyword (M4 prep)
|
||||||
|
// FA_TEST_NONEXISTENT_SUB_ID ID guaranteed to 404 (default: 9999999999)
|
||||||
|
|
||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
"github.com/gocolly/colly/v2"
|
||||||
|
|
||||||
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fixtureTarget defines one HTML file to capture. requires lists env-var
|
||||||
|
// names that must be set (after defaults are resolved) for this fixture to
|
||||||
|
// be attempted; targets with missing prerequisites are skipped, not failed.
|
||||||
|
type fixtureTarget struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
requires []string // already-resolved values to check non-empty
|
||||||
|
notes string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshFixtures(t *testing.T) {
|
||||||
|
a := os.Getenv("FA_A")
|
||||||
|
b := os.Getenv("FA_B")
|
||||||
|
if a == "" || b == "" {
|
||||||
|
t.Skip("FA_A / FA_B not set; cannot refresh fixtures")
|
||||||
|
}
|
||||||
|
cf := os.Getenv("CF_CLEARANCE")
|
||||||
|
ua := os.Getenv("FA_UA")
|
||||||
|
if cf == "" || ua == "" {
|
||||||
|
t.Log("warning: CF_CLEARANCE or FA_UA not set; refresh likely to hit a Cloudflare challenge")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(fixturesDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", fixturesDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := New(
|
||||||
|
WithCookies(Cookies{A: a, B: b}),
|
||||||
|
WithCloudflare(CFCookies{Clearance: cf}),
|
||||||
|
WithUserAgent(ua),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resolve targets every fixture is gated on the relevant env-derived
|
||||||
|
// values being non-empty so an incomplete env still gets you the
|
||||||
|
// fixtures you can capture.
|
||||||
|
user := os.Getenv("FA_TEST_USER")
|
||||||
|
galleryUser := envOr("FA_TEST_GALLERY_USER", user)
|
||||||
|
scrapsUser := envOr("FA_TEST_SCRAPS_USER", galleryUser)
|
||||||
|
favoritesUser := envOr("FA_TEST_FAVORITES_USER", user)
|
||||||
|
journalsUser := envOr("FA_TEST_JOURNALS_USER", user)
|
||||||
|
shoutsUser := os.Getenv("FA_TEST_USER_WITH_SHOUTS")
|
||||||
|
bannerUser := os.Getenv("FA_TEST_USER_WITH_BANNER")
|
||||||
|
searchQuery := os.Getenv("FA_TEST_SEARCH_QUERY")
|
||||||
|
|
||||||
|
subID := atoi64Default(os.Getenv("FA_TEST_SUB_ID"), 12345678)
|
||||||
|
storyID := atoi64Default(os.Getenv("FA_TEST_SUB_STORY_ID"), 0)
|
||||||
|
journalID := atoi64Default(os.Getenv("FA_TEST_JOURNAL_ID"), 0)
|
||||||
|
noteID := atoi64Default(os.Getenv("FA_TEST_NOTE_ID"), 0)
|
||||||
|
galleryLastPage := atoiDefault(os.Getenv("FA_TEST_GALLERY_LAST_PAGE"), 0)
|
||||||
|
missingSubID := atoi64Default(os.Getenv("FA_TEST_NONEXISTENT_SUB_ID"), 9999999999)
|
||||||
|
|
||||||
|
targets := []fixtureTarget{
|
||||||
|
// ---- M1: read API verifiable today --------------------------------
|
||||||
|
{
|
||||||
|
name: "submission.html",
|
||||||
|
url: urls.Submission(subID),
|
||||||
|
requires: []string{strconv.FormatInt(subID, 10)},
|
||||||
|
notes: "image submission used by parseSubmission tests + comments parser",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "submission_story.html",
|
||||||
|
url: urls.Submission(storyID),
|
||||||
|
requires: []string{strconv.FormatInt(storyID, 10)},
|
||||||
|
notes: "non-image submission (story/music/PDF) exercises FileURL fallback to Download button",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user.html",
|
||||||
|
url: urls.User(user),
|
||||||
|
requires: []string{user},
|
||||||
|
notes: "user profile used by parseUser tests",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user_with_shouts.html",
|
||||||
|
url: urls.User(shoutsUser),
|
||||||
|
requires: []string{shoutsUser},
|
||||||
|
notes: "profile that exposes shouts used to validate shouts parser",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user_with_banner.html",
|
||||||
|
url: urls.User(bannerUser),
|
||||||
|
requires: []string{bannerUser},
|
||||||
|
notes: "profile that has a custom uploaded site banner used to validate SiteBanner.IsCustom",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gallery_page1.html",
|
||||||
|
url: urls.Gallery(galleryUser, 1),
|
||||||
|
requires: []string{galleryUser},
|
||||||
|
notes: "first gallery page figure[id^=sid-] iteration",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gallery_page_last.html",
|
||||||
|
url: urls.Gallery(galleryUser, galleryLastPage),
|
||||||
|
requires: []string{galleryUser, strconv.Itoa(galleryLastPage)},
|
||||||
|
notes: "last gallery page verifies detectNextPage returns false at the end",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "scraps_page1.html",
|
||||||
|
url: urls.Scraps(scrapsUser, 1),
|
||||||
|
requires: []string{scrapsUser},
|
||||||
|
notes: "scraps listing same parser as gallery; sanity-check shape",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "favorites_page1.html",
|
||||||
|
url: urls.Favorites(favoritesUser, 1),
|
||||||
|
requires: []string{favoritesUser},
|
||||||
|
notes: "favorites per-item Author should be the original artist",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "journals_listing_page1.html",
|
||||||
|
url: urls.UserJournals(journalsUser, 1),
|
||||||
|
requires: []string{journalsUser},
|
||||||
|
notes: "journals listing used by UserJournals iterator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "journal.html",
|
||||||
|
url: urls.Journal(journalID),
|
||||||
|
requires: []string{strconv.FormatInt(journalID, 10)},
|
||||||
|
notes: "single journal entry parseJournal target",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "comments_submission.html",
|
||||||
|
url: urls.Submission(subID),
|
||||||
|
requires: []string{strconv.FormatInt(subID, 10)},
|
||||||
|
notes: "submission page captured a second time for comment-parser fixture (comments are inline)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "comments_journal.html",
|
||||||
|
url: urls.Journal(journalID),
|
||||||
|
requires: []string{strconv.FormatInt(journalID, 10)},
|
||||||
|
notes: "journal page captured for journal comments parsing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "system_message_not_found.html",
|
||||||
|
url: urls.Submission(missingSubID),
|
||||||
|
requires: []string{strconv.FormatInt(missingSubID, 10)},
|
||||||
|
notes: "captures FA's system-message page for ErrNotFound classifier validation",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- M2: inbox/notes (parsers not yet written; captures for prep) -
|
||||||
|
{
|
||||||
|
name: "msg_submissions.html",
|
||||||
|
url: urls.MsgSubmissions(),
|
||||||
|
requires: []string{a},
|
||||||
|
notes: "M2 prep: new-submission inbox (auth required)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "msg_others.html",
|
||||||
|
url: urls.MsgOthers(),
|
||||||
|
requires: []string{a},
|
||||||
|
notes: "M2 prep: watch/journal/comment/fav notifications",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "msg_pms.html",
|
||||||
|
url: urls.MsgPMs(),
|
||||||
|
requires: []string{a},
|
||||||
|
notes: "M2 prep: private-message inbox",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "note_view.html",
|
||||||
|
url: urls.ViewMessage(noteID),
|
||||||
|
requires: []string{strconv.FormatInt(noteID, 10)},
|
||||||
|
notes: "M2 prep: single note view (needs FA_TEST_NOTE_ID)",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- M4: search/browse (parsers not yet written; captures for prep)
|
||||||
|
{
|
||||||
|
name: "search_results.html",
|
||||||
|
url: urls.Search(searchQuery, 1),
|
||||||
|
requires: []string{searchQuery},
|
||||||
|
notes: "M4 prep: search results page",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "browse.html",
|
||||||
|
url: urls.Browse(1),
|
||||||
|
requires: []string{a},
|
||||||
|
notes: "M4 prep: /browse/ page",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tg := range targets {
|
||||||
|
t.Run(tg.name, func(t *testing.T) {
|
||||||
|
for _, r := range tg.requires {
|
||||||
|
if strings.TrimSpace(r) == "" || r == "0" {
|
||||||
|
t.Skipf("required input not set; skipping (%s)", tg.notes)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw, err := fetchRaw(t.Context(), client, tg.url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetch %s (%s): %v", tg.name, tg.url, err)
|
||||||
|
}
|
||||||
|
if doc, derr := goquery.NewDocumentFromReader(bytes.NewReader(raw)); derr == nil {
|
||||||
|
if title := strings.TrimSpace(doc.Find("title").First().Text()); title == "Just a moment..." {
|
||||||
|
t.Fatalf("got Cloudflare challenge page; refresh CF_CLEARANCE / FA_UA")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := filepath.Join(fixturesDir, tg.name)
|
||||||
|
if err := os.WriteFile(out, raw, 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", out, err)
|
||||||
|
}
|
||||||
|
t.Logf("wrote %s (%d bytes) %s", out, len(raw), tg.notes)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchRaw fetches the URL through the same Colly+transport pipeline the SDK
|
||||||
|
// uses for parsed calls, but hands back the raw response body instead of
|
||||||
|
// running a parser. Lives in the test build so we don't expose a public
|
||||||
|
// raw-fetch API just for fixture refreshing.
|
||||||
|
func fetchRaw(ctx context.Context, c *Client, rawURL string) ([]byte, error) {
|
||||||
|
clone := c.collector.Clone()
|
||||||
|
clone.SetClient(c.http)
|
||||||
|
clone.SetCookieJar(c.jar)
|
||||||
|
clone.Context = ctx
|
||||||
|
|
||||||
|
var body []byte
|
||||||
|
var respErr error
|
||||||
|
|
||||||
|
clone.OnResponse(func(r *colly.Response) {
|
||||||
|
// Copy: r.Body is reused by Colly across responses.
|
||||||
|
body = append(body[:0], r.Body...)
|
||||||
|
})
|
||||||
|
clone.OnError(func(r *colly.Response, err error) {
|
||||||
|
respErr = err
|
||||||
|
})
|
||||||
|
if err := clone.Visit(rawURL); err != nil {
|
||||||
|
if respErr != nil {
|
||||||
|
return nil, respErr
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if respErr != nil {
|
||||||
|
return nil, respErr
|
||||||
|
}
|
||||||
|
if len(body) == 0 {
|
||||||
|
return nil, errors.New("fetchRaw: empty body")
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// atoi64Default parses s as an int64; on any failure returns fallback.
|
||||||
|
func atoi64Default(s string, fallback int64) int64 {
|
||||||
|
if s == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(s, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// atoiDefault parses s as an int; on any failure returns fallback.
|
||||||
|
func atoiDefault(s string, fallback int) int {
|
||||||
|
if s == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
28
fixtures_test.go
Normal file
28
fixtures_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fixturesDir is where captured FA HTML responses live. The refresh tool
|
||||||
|
// (see fixtures_refresh_test.go, build tag `fixtures`) writes here.
|
||||||
|
const fixturesDir = "testdata/html"
|
||||||
|
|
||||||
|
// loadFixture reads a captured HTML fixture by name and skips the test if
|
||||||
|
// the file is missing. Pairs with the `fixtures` build-tagged refresh test:
|
||||||
|
// if you've never run the refresh, parser tests against real HTML are skipped
|
||||||
|
// cleanly instead of failing.
|
||||||
|
func loadFixture(t *testing.T, name string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(fixturesDir, name)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
t.Skipf("fixture %s not present run `go test -tags=fixtures` with FA_* env vars to refresh", path)
|
||||||
|
}
|
||||||
|
t.Fatalf("read fixture %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
76
gallery.go
Normal file
76
gallery.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"iter"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Gallery iterates the submissions in a user's main gallery, newest first.
|
||||||
|
//
|
||||||
|
// Each yielded *Submission carries only the fields visible on the listing
|
||||||
|
// page: ID, Title, Author (for favorites), ThumbURL, and Rating. Call
|
||||||
|
// [Client.GetSubmission] with the ID to load the full record.
|
||||||
|
func (c *Client) Gallery(ctx context.Context, name string, opts ListOptions) iter.Seq2[*Submission, error] {
|
||||||
|
return c.listGallerySection(ctx, name, urls.Gallery, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scraps iterates the user's scraps folder. Same yield shape as Gallery.
|
||||||
|
func (c *Client) Scraps(ctx context.Context, name string, opts ListOptions) iter.Seq2[*Submission, error] {
|
||||||
|
return c.listGallerySection(ctx, name, urls.Scraps, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favorites iterates the user's favorited submissions. The yielded
|
||||||
|
// *Submission's Author field reflects the original artist (not the user
|
||||||
|
// whose favorites we are walking).
|
||||||
|
func (c *Client) Favorites(ctx context.Context, name string, opts ListOptions) iter.Seq2[*Submission, error] {
|
||||||
|
return c.listGallerySection(ctx, name, urls.Favorites, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listGallerySection is the shared engine for Gallery / Scraps / Favorites.
|
||||||
|
// urlFn picks the section-specific URL builder; the rest of the pagination
|
||||||
|
// machinery is identical across all three sections.
|
||||||
|
func (c *Client) listGallerySection(
|
||||||
|
ctx context.Context,
|
||||||
|
name string,
|
||||||
|
urlFn func(string, int) string,
|
||||||
|
opts ListOptions,
|
||||||
|
) iter.Seq2[*Submission, error] {
|
||||||
|
return func(yield func(*Submission, error) bool) {
|
||||||
|
page := opts.firstPage()
|
||||||
|
pagesFetched := 0
|
||||||
|
for {
|
||||||
|
if opts.reachedLimit(pagesFetched) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
items []*Submission
|
||||||
|
hasNext bool
|
||||||
|
)
|
||||||
|
err := c.fetch(ctx, urlFn(name, page), func(doc *goquery.Document) error {
|
||||||
|
items, hasNext = parseGalleryPage(doc, c.cfg.jsonListings)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
yield(nil, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pagesFetched++
|
||||||
|
if len(items) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, s := range items {
|
||||||
|
if !yield(s, nil) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasNext {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
107
gallery_parser.go
Normal file
107
gallery_parser.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseGalleryPage parses one page of /gallery/, /scraps/, /favorites/, or
|
||||||
|
// /browse/, returning each submission preview and whether a next page
|
||||||
|
// exists.
|
||||||
|
//
|
||||||
|
// useJSON controls the experimental JSON-first merge: when true, the
|
||||||
|
// parser reads the embedded js-submissionData blob first and uses it as
|
||||||
|
// the primary source for title/author/avatar; HTML scraping covers what
|
||||||
|
// the JSON doesn't carry (rating, thumb, ID). When false the parser is
|
||||||
|
// pure HTML the same behaviour as before [WithExperimentalJSONListings]
|
||||||
|
// existed.
|
||||||
|
func parseGalleryPage(doc *goquery.Document, useJSON bool) (items []*Submission, hasNext bool) {
|
||||||
|
var jsonData listingJSONMap
|
||||||
|
if useJSON {
|
||||||
|
jsonData = readListingJSON(doc)
|
||||||
|
}
|
||||||
|
doc.Find("figure[id^=sid-]").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
if s := parseGalleryFigure(sel, jsonData); s != nil {
|
||||||
|
items = append(items, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
hasNext = detectNextPage(doc)
|
||||||
|
return items, hasNext
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGalleryFigure lifts a single submission preview from a
|
||||||
|
// <figure id="sid-…"> element. Shared between gallery, browse, favorites,
|
||||||
|
// search, and the submission inbox.
|
||||||
|
//
|
||||||
|
// When jsonData is non-nil and contains an entry for this submission's
|
||||||
|
// ID, the JSON values win for title/author display name/lower-cased name/
|
||||||
|
// avatar. Rating, ThumbURL, and ID always come from the HTML those
|
||||||
|
// aren't represented in the JSON blob.
|
||||||
|
func parseGalleryFigure(sel *goquery.Selection, jsonData listingJSONMap) *Submission {
|
||||||
|
idAttr, _ := sel.Attr("id")
|
||||||
|
idStr := strings.TrimPrefix(idAttr, "sid-")
|
||||||
|
id, err := parseID[SubmissionID](idStr)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := &Submission{ID: id}
|
||||||
|
|
||||||
|
viewLink := sel.Find("a[href^='/view/']").First()
|
||||||
|
if viewLink.Length() > 0 {
|
||||||
|
s.Title = firstNonEmpty(
|
||||||
|
trimAttr(viewLink, "title"),
|
||||||
|
trimText(sel.Find("figcaption p:first-child").First()),
|
||||||
|
trimText(viewLink),
|
||||||
|
)
|
||||||
|
img := viewLink.Find("img").First()
|
||||||
|
s.ThumbURL = urls.AbsoluteCDN(firstNonEmpty(
|
||||||
|
trimAttr(img, "data-src"),
|
||||||
|
trimAttr(img, "src"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rating class on the figure: figure.t-image.r-general (et al.)
|
||||||
|
class, _ := sel.Attr("class")
|
||||||
|
switch {
|
||||||
|
case strings.Contains(class, "r-adult"):
|
||||||
|
s.Rating = RatingAdult
|
||||||
|
case strings.Contains(class, "r-mature"):
|
||||||
|
s.Rating = RatingMature
|
||||||
|
case strings.Contains(class, "r-general"):
|
||||||
|
s.Rating = RatingGeneral
|
||||||
|
}
|
||||||
|
|
||||||
|
// Author from figcaption (favorites/browse render an artist link there).
|
||||||
|
if author := sel.Find("figcaption a[href^='/user/']").First(); author.Length() > 0 {
|
||||||
|
href, _ := author.Attr("href")
|
||||||
|
s.Author = UserRef{
|
||||||
|
DisplayName: trimText(author),
|
||||||
|
}
|
||||||
|
if parts := strings.Split(strings.Trim(href, "/"), "/"); len(parts) >= 2 {
|
||||||
|
s.Author.Name = strings.ToLower(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON enrichment preferred sources for the fields it carries.
|
||||||
|
if jsonData != nil {
|
||||||
|
if entry, ok := jsonData[id]; ok {
|
||||||
|
if entry.Title != "" {
|
||||||
|
s.Title = entry.Title
|
||||||
|
}
|
||||||
|
if entry.Username != "" {
|
||||||
|
s.Author.DisplayName = entry.Username
|
||||||
|
}
|
||||||
|
if entry.Lower != "" {
|
||||||
|
s.Author.Name = entry.Lower
|
||||||
|
}
|
||||||
|
if av := avatarURLFromMtime(entry.Lower, entry.AvatarMtime); av != "" {
|
||||||
|
s.Author.AvatarURL = av
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
83
gallery_parser_test.go
Normal file
83
gallery_parser_test.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
)
|
||||||
|
|
||||||
|
const syntheticGalleryHTML = `<html><body>
|
||||||
|
<figure id="sid-1001" class="t-image r-general">
|
||||||
|
<a href="/view/1001/" title="Submission One">
|
||||||
|
<img src="//d.example/thumb/1001.png" data-src="//d.example/thumb/1001.png"/>
|
||||||
|
</a>
|
||||||
|
<figcaption>
|
||||||
|
<p>Submission One</p>
|
||||||
|
<a href="/user/artistone/">ArtistOne</a>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure id="sid-1002" class="t-image r-adult">
|
||||||
|
<a href="/view/1002/" title="Submission Two">
|
||||||
|
<img src="//d.example/thumb/1002.png"/>
|
||||||
|
</a>
|
||||||
|
<figcaption>
|
||||||
|
<p>Submission Two</p>
|
||||||
|
<a href="/user/artisttwo/">ArtistTwo</a>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
<a class="button standard" href="/gallery/me/2/">Next</a>
|
||||||
|
</body></html>`
|
||||||
|
|
||||||
|
func TestParseGalleryPage_Synthetic(t *testing.T) {
|
||||||
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(syntheticGalleryHTML))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
items, hasNext := parseGalleryPage(doc, false)
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("items = %d; want 2", len(items))
|
||||||
|
}
|
||||||
|
if items[0].ID != 1001 || items[1].ID != 1002 {
|
||||||
|
t.Errorf("ids = [%d, %d]", items[0].ID, items[1].ID)
|
||||||
|
}
|
||||||
|
if items[0].Title != "Submission One" {
|
||||||
|
t.Errorf("items[0].Title = %q", items[0].Title)
|
||||||
|
}
|
||||||
|
if items[0].Rating != RatingGeneral {
|
||||||
|
t.Errorf("items[0].Rating = %q; want General", items[0].Rating)
|
||||||
|
}
|
||||||
|
if items[1].Rating != RatingAdult {
|
||||||
|
t.Errorf("items[1].Rating = %q; want Adult", items[1].Rating)
|
||||||
|
}
|
||||||
|
if items[0].Author.Name != "artistone" {
|
||||||
|
t.Errorf("items[0].Author.Name = %q", items[0].Author.Name)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(items[0].ThumbURL, "https://") {
|
||||||
|
t.Errorf("items[0].ThumbURL = %q; want absolute URL", items[0].ThumbURL)
|
||||||
|
}
|
||||||
|
if !hasNext {
|
||||||
|
t.Error("hasNext = false; want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseGalleryPage_RealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "gallery_page1.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
items, _ := parseGalleryPage(doc, false)
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Fatal("real fixture: no items parsed")
|
||||||
|
}
|
||||||
|
for i, it := range items {
|
||||||
|
if it.ID == 0 {
|
||||||
|
t.Errorf("item %d: ID == 0", i)
|
||||||
|
}
|
||||||
|
if it.Title == "" {
|
||||||
|
t.Errorf("item %d: empty Title", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
go.mod
Normal file
28
go.mod
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
module git.anthrove.art/public/go-fa-api
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/PuerkitoBio/goquery v1.12.0
|
||||||
|
github.com/gocolly/colly/v2 v2.3.0
|
||||||
|
golang.org/x/time v0.15.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||||
|
github.com/antchfx/htmlquery v1.3.5 // indirect
|
||||||
|
github.com/antchfx/xmlquery v1.5.0 // indirect
|
||||||
|
github.com/antchfx/xpath v1.3.5 // indirect
|
||||||
|
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||||
|
github.com/gobwas/glob v0.2.3 // indirect
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
|
github.com/kennygrant/sanitize v1.2.4 // indirect
|
||||||
|
github.com/nlnwa/whatwg-url v0.6.2 // indirect
|
||||||
|
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
|
||||||
|
github.com/temoto/robotstxt v1.1.2 // indirect
|
||||||
|
golang.org/x/net v0.52.0 // indirect
|
||||||
|
golang.org/x/text v0.35.0 // indirect
|
||||||
|
google.golang.org/appengine v1.6.8 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
)
|
||||||
125
go.sum
Normal file
125
go.sum
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||||
|
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||||
|
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||||
|
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||||
|
github.com/antchfx/htmlquery v1.3.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0=
|
||||||
|
github.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA=
|
||||||
|
github.com/antchfx/xmlquery v1.5.0 h1:uAi+mO40ZWfyU6mlUBxRVvL6uBNZ6LMU4M3+mQIBV4c=
|
||||||
|
github.com/antchfx/xmlquery v1.5.0/go.mod h1:lJfWRXzYMK1ss32zm1GQV3gMIW/HFey3xDZmkP1SuNc=
|
||||||
|
github.com/antchfx/xpath v1.3.5 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ=
|
||||||
|
github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||||
|
github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||||
|
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
|
||||||
|
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||||
|
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||||
|
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||||
|
github.com/gocolly/colly/v2 v2.3.0 h1:HSFh0ckbgVd2CSGRE+Y/iA4goUhGROJwyQDCMXGFBWM=
|
||||||
|
github.com/gocolly/colly/v2 v2.3.0/go.mod h1:Qp54s/kQbwCQvFVx8KzKCSTXVJ1wWT4QeAKEu33x1q8=
|
||||||
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o=
|
||||||
|
github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak=
|
||||||
|
github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q=
|
||||||
|
github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
|
||||||
|
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg=
|
||||||
|
github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
|
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||||
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||||
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||||
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
|
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
||||||
|
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
36
ids.go
Normal file
36
ids.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SubmissionID identifies a submission. FA exposes these as positive integers
|
||||||
|
// in URLs of the form /view/{id}/.
|
||||||
|
type SubmissionID int64
|
||||||
|
|
||||||
|
func (id SubmissionID) String() string { return strconv.FormatInt(int64(id), 10) }
|
||||||
|
|
||||||
|
// JournalID identifies a journal entry. URL form: /journal/{id}/.
|
||||||
|
type JournalID int64
|
||||||
|
|
||||||
|
func (id JournalID) String() string { return strconv.FormatInt(int64(id), 10) }
|
||||||
|
|
||||||
|
// CommentID identifies a comment within a submission or journal page.
|
||||||
|
type CommentID int64
|
||||||
|
|
||||||
|
func (id CommentID) String() string { return strconv.FormatInt(int64(id), 10) }
|
||||||
|
|
||||||
|
// parseID parses a positive integer from a string and returns it as T.
|
||||||
|
// Returns 0 and a wrapped error if s is empty or non-numeric. Used by parsers
|
||||||
|
// to extract IDs from hrefs.
|
||||||
|
func parseID[T ~int64](s string) (T, error) {
|
||||||
|
if s == "" {
|
||||||
|
return 0, fmt.Errorf("parse id: empty string")
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(s, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("parse id %q: %w", s, err)
|
||||||
|
}
|
||||||
|
return T(n), nil
|
||||||
|
}
|
||||||
168
inbox.go
Normal file
168
inbox.go
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"iter"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SubmissionInbox iterates the new-submission inbox at /msg/submissions/ —
|
||||||
|
// the feed of submissions posted since you last cleared the inbox by users
|
||||||
|
// you watch. Requires a logged-in client; anonymous calls hit the login
|
||||||
|
// gate and surface as [ErrUnauthorized].
|
||||||
|
//
|
||||||
|
// Each yielded *Submission carries ID, Title, Author, ThumbURL, Rating,
|
||||||
|
// and PostedAt (derived from the date-divider's data-date timestamp).
|
||||||
|
// Items are yielded in document order newest first, grouped by day.
|
||||||
|
//
|
||||||
|
// Pagination follows FA's cursor scheme (the "Next 72" link encodes
|
||||||
|
// "submissions newer than ID X, 72 per page" in its href). When FA serves
|
||||||
|
// a full page but omits that link, the iterator synthesizes the next
|
||||||
|
// cursor from the oldest submission on the page so a large inbox is not
|
||||||
|
// truncated to its first page. Iteration stops once a page yields no new
|
||||||
|
// submissions, or returns fewer than a full page with no cursor link.
|
||||||
|
//
|
||||||
|
// Use [ListOptions.MaxPages] to bound the crawl; the inbox can hold
|
||||||
|
// hundreds of pending items if you watch many active artists.
|
||||||
|
//
|
||||||
|
// ListOptions.StartPage is ignored the inbox is cursor-paginated by
|
||||||
|
// FA (the "Next 72" link encodes a from-id), not page-numbered, so there
|
||||||
|
// is nothing meaningful to start from.
|
||||||
|
func (c *Client) SubmissionInbox(ctx context.Context, opts ListOptions) iter.Seq2[*Submission, error] {
|
||||||
|
return func(yield func(*Submission, error) bool) {
|
||||||
|
nextURL := urls.MsgSubmissions()
|
||||||
|
pagesFetched := 0
|
||||||
|
visited := make(map[string]bool)
|
||||||
|
seen := make(map[SubmissionID]bool)
|
||||||
|
for nextURL != "" {
|
||||||
|
if opts.reachedLimit(pagesFetched) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Loop guard: FA (or a synthesized cursor) can point back at a
|
||||||
|
// page already crawled; stop rather than spin forever.
|
||||||
|
if visited[nextURL] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
visited[nextURL] = true
|
||||||
|
|
||||||
|
var (
|
||||||
|
items []*Submission
|
||||||
|
next string
|
||||||
|
)
|
||||||
|
err := c.fetch(ctx, nextURL, func(doc *goquery.Document) error {
|
||||||
|
items, next = parseSubmissionInboxPage(doc, c.cfg.jsonListings)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
yield(nil, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pagesFetched++
|
||||||
|
|
||||||
|
newCount := 0
|
||||||
|
minID := SubmissionID(0)
|
||||||
|
for _, s := range items {
|
||||||
|
if minID == 0 || s.ID < minID {
|
||||||
|
minID = s.ID
|
||||||
|
}
|
||||||
|
if seen[s.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[s.ID] = true
|
||||||
|
newCount++
|
||||||
|
if !yield(s, nil) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A page that adds nothing new is the natural end of the crawl.
|
||||||
|
if newCount == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// FA renders a "Next 72" cursor link on every page that has a
|
||||||
|
// successor but it can omit it even when the inbox holds more.
|
||||||
|
// When the page came back full, trust the item count over the
|
||||||
|
// missing link and synthesize the cursor from the oldest id.
|
||||||
|
if next == "" {
|
||||||
|
if len(items) >= urls.InboxPageSize && minID > 0 {
|
||||||
|
next = urls.MsgSubmissionsCursor(int64(minID))
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextURL = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSubmissionInboxPage walks /msg/submissions/ (or one of its cursor-
|
||||||
|
// paginated variants), returning each yielded submission and the absolute
|
||||||
|
// URL of the "Next 72" cursor page, or "" if there's no further page.
|
||||||
|
//
|
||||||
|
// Inbox items are grouped under <div class="notifications-by-date"
|
||||||
|
// data-date="UNIXTIME"> wrappers; the parser lifts the group timestamp
|
||||||
|
// onto each contained submission's PostedAt so callers don't have to
|
||||||
|
// re-derive it.
|
||||||
|
//
|
||||||
|
// useJSON controls the experimental JSON-first merge see parseGalleryPage.
|
||||||
|
func parseSubmissionInboxPage(doc *goquery.Document, useJSON bool) (items []*Submission, nextURL string) {
|
||||||
|
var jsonData listingJSONMap
|
||||||
|
if useJSON {
|
||||||
|
jsonData = readListingJSON(doc)
|
||||||
|
}
|
||||||
|
doc.Find("#messagecenter-submissions div.notifications-by-date").Each(func(_ int, group *goquery.Selection) {
|
||||||
|
groupTime := groupDate(group)
|
||||||
|
group.Find("figure[id^=sid-]").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
s := parseGalleryFigure(sel, jsonData)
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.PostedAt.IsZero() && !groupTime.IsZero() {
|
||||||
|
s.PostedAt = groupTime
|
||||||
|
}
|
||||||
|
items = append(items, s)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if len(items) == 0 {
|
||||||
|
doc.Find("#messagecenter-submissions figure[id^=sid-]").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
if s := parseGalleryFigure(sel, jsonData); s != nil {
|
||||||
|
items = append(items, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Last resort: a cursor page may drop the #messagecenter-submissions
|
||||||
|
// wrapper entirely. /msg/submissions/ carries no figures other than the
|
||||||
|
// inbox gallery, so a document-wide sweep is safe here.
|
||||||
|
if len(items) == 0 {
|
||||||
|
doc.Find("figure[id^=sid-]").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
if s := parseGalleryFigure(sel, jsonData); s != nil {
|
||||||
|
items = append(items, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if next := doc.Find("div.messagecenter-navigation a.button.more").First(); next.Length() > 0 {
|
||||||
|
href, _ := next.Attr("href")
|
||||||
|
nextURL = urls.AbsoluteCDN(href)
|
||||||
|
}
|
||||||
|
return items, nextURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupDate reads the unix timestamp from a notifications-by-date wrapper's
|
||||||
|
// data-date attribute. Returns zero time when missing/unparseable.
|
||||||
|
func groupDate(group *goquery.Selection) time.Time {
|
||||||
|
v := strings.TrimSpace(trimAttr(group, "data-date"))
|
||||||
|
if v == "" {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
secs, err := strconv.ParseInt(v, 10, 64)
|
||||||
|
if err != nil || secs <= 0 {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return time.Unix(secs, 0).UTC()
|
||||||
|
}
|
||||||
32
inbox_test.go
Normal file
32
inbox_test.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A cursor page (/msg/submissions/new~{id}@72/) may render its gallery
|
||||||
|
// without the #messagecenter-submissions wrapper the default view uses.
|
||||||
|
// parseSubmissionInboxPage must still recover the submissions instead of
|
||||||
|
// silently returning an empty page, which would truncate the crawl.
|
||||||
|
func TestParseSubmissionInboxPage_FindsFiguresOutsideMessageCenter(t *testing.T) {
|
||||||
|
html := `<html><body>
|
||||||
|
<section class="gallery">
|
||||||
|
<figure id="sid-501"><a href="/view/501/" title="A"><img src="/a.png"/></a></figure>
|
||||||
|
<figure id="sid-500"><a href="/view/500/" title="B"><img src="/b.png"/></a></figure>
|
||||||
|
</section>
|
||||||
|
</body></html>`
|
||||||
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
items, _ := parseSubmissionInboxPage(doc, false)
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("got %d items; want 2", len(items))
|
||||||
|
}
|
||||||
|
if items[0].ID != 501 || items[1].ID != 500 {
|
||||||
|
t.Errorf("ids = %d, %d; want 501, 500", items[0].ID, items[1].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
147
internal/urls/routes.go
Normal file
147
internal/urls/routes.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
// Package urls is the single source of truth for every FA URL the SDK
|
||||||
|
// constructs. Centralising route building here keeps fragile path
|
||||||
|
// concatenation out of the public API and makes the site's URL scheme
|
||||||
|
// trivial to swap (e.g., were FA to move endpoints).
|
||||||
|
package urls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Host is the canonical FA host. Exported so callers can override for
|
||||||
|
// proxies or local mirrors, but the production value is what every
|
||||||
|
// builder below uses.
|
||||||
|
const Host = "https://www.furaffinity.net"
|
||||||
|
|
||||||
|
// Submission returns the canonical URL for viewing a submission.
|
||||||
|
func Submission(id int64) string {
|
||||||
|
return Host + "/view/" + strconv.FormatInt(id, 10) + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// User returns the URL for a user's profile page.
|
||||||
|
func User(name string) string {
|
||||||
|
return Host + "/user/" + safeName(name) + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gallery returns the URL for a user's main gallery page.
|
||||||
|
func Gallery(name string, page int) string {
|
||||||
|
return Host + "/gallery/" + safeName(name) + "/" + pageSegment(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scraps returns the URL for a user's scraps page.
|
||||||
|
func Scraps(name string, page int) string {
|
||||||
|
return Host + "/scraps/" + safeName(name) + "/" + pageSegment(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favorites returns the URL for a user's favorites page. FA uses a numeric
|
||||||
|
// page parameter; the first page is 1.
|
||||||
|
func Favorites(name string, page int) string {
|
||||||
|
return Host + "/favorites/" + safeName(name) + "/" + pageSegment(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Journal returns the URL for a single journal entry.
|
||||||
|
func Journal(id int64) string {
|
||||||
|
return Host + "/journal/" + strconv.FormatInt(id, 10) + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserJournals returns the URL for a user's journals listing.
|
||||||
|
func UserJournals(name string, page int) string {
|
||||||
|
return Host + "/journals/" + safeName(name) + "/" + pageSegment(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MsgSubmissions returns the URL for the new-submission inbox. Requires auth.
|
||||||
|
func MsgSubmissions() string {
|
||||||
|
return Host + "/msg/submissions/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// InboxPageSize is FA's fixed page size for the submission inbox its
|
||||||
|
// pagination links and "Next N" label are always built around 72 items.
|
||||||
|
const InboxPageSize = 72
|
||||||
|
|
||||||
|
// MsgSubmissionsCursor returns the URL for the new-submission inbox page
|
||||||
|
// that begins just below submission id FA's "new~{id}@72" cursor scheme.
|
||||||
|
// Used to keep crawling when FA omits the rendered "Next 72" link.
|
||||||
|
func MsgSubmissionsCursor(id int64) string {
|
||||||
|
return Host + "/msg/submissions/new~" +
|
||||||
|
strconv.FormatInt(id, 10) + "@" +
|
||||||
|
strconv.Itoa(InboxPageSize) + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MsgOthers returns the URL for the watch/journal/comment/fav notifications
|
||||||
|
// page. Requires auth.
|
||||||
|
func MsgOthers() string {
|
||||||
|
return Host + "/msg/others/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MsgPMs returns the URL for the private-message inbox. Requires auth.
|
||||||
|
func MsgPMs() string {
|
||||||
|
return Host + "/msg/pms/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ViewMessage returns the URL for a single private message (note) by ID.
|
||||||
|
// Requires auth.
|
||||||
|
func ViewMessage(id int64) string {
|
||||||
|
return Host + "/viewmessage/" + strconv.FormatInt(id, 10) + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search returns the URL for a keyword search. FA accepts the query string
|
||||||
|
// directly; pagination is a query param rather than a path segment.
|
||||||
|
func Search(query string, page int) string {
|
||||||
|
u := Host + "/search/"
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("q", query)
|
||||||
|
if page > 1 {
|
||||||
|
q.Set("page", strconv.Itoa(page))
|
||||||
|
}
|
||||||
|
if e := q.Encode(); e != "" {
|
||||||
|
u += "?" + e
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browse returns the URL for /browse/ with optional page index. FA's
|
||||||
|
// browse UI navigates via POST forms, but a GET with ?page=N is honoured
|
||||||
|
// for the rendered results page, which is all this SDK needs.
|
||||||
|
func Browse(page int) string {
|
||||||
|
if page <= 1 {
|
||||||
|
return Host + "/browse/"
|
||||||
|
}
|
||||||
|
return Host + "/browse/?page=" + strconv.Itoa(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeName lower-cases and URL-escapes a username segment. FA folds names
|
||||||
|
// to lowercase for URL routing.
|
||||||
|
func safeName(name string) string {
|
||||||
|
return url.PathEscape(strings.ToLower(strings.TrimSpace(name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageSegment renders a 1-based page index as a trailing path segment.
|
||||||
|
// Returns the empty string for page <= 1 so the first page URL matches the
|
||||||
|
// canonical form FA emits in its own "next page" links.
|
||||||
|
func pageSegment(page int) string {
|
||||||
|
if page <= 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.Itoa(page) + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// AbsoluteCDN turns an //d.furaffinity.net/... or /art/... reference into a
|
||||||
|
// fully qualified https URL. Returns s unchanged if it already has a scheme.
|
||||||
|
func AbsoluteCDN(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
switch {
|
||||||
|
case s == "":
|
||||||
|
return ""
|
||||||
|
case strings.HasPrefix(s, "http://"), strings.HasPrefix(s, "https://"):
|
||||||
|
return s
|
||||||
|
case strings.HasPrefix(s, "//"):
|
||||||
|
return "https:" + s
|
||||||
|
case strings.HasPrefix(s, "/"):
|
||||||
|
return Host + s
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%s/%s", Host, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
65
internal/urls/routes_test.go
Normal file
65
internal/urls/routes_test.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package urls
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSubmission(t *testing.T) {
|
||||||
|
got := Submission(42)
|
||||||
|
want := "https://www.furaffinity.net/view/42/"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Submission(42) = %q; want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_LowercasesAndEscapes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, in, want string
|
||||||
|
}{
|
||||||
|
{"plain", "SomeUser", "https://www.furaffinity.net/user/someuser/"},
|
||||||
|
{"trim", " Mixed ", "https://www.furaffinity.net/user/mixed/"},
|
||||||
|
{"unicode safe", "über", "https://www.furaffinity.net/user/%C3%BCber/"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := User(tc.in); got != tc.want {
|
||||||
|
t.Errorf("User(%q) = %q; want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGallery_PageSegments(t *testing.T) {
|
||||||
|
cases := map[int]string{
|
||||||
|
1: "https://www.furaffinity.net/gallery/me/",
|
||||||
|
2: "https://www.furaffinity.net/gallery/me/2/",
|
||||||
|
10: "https://www.furaffinity.net/gallery/me/10/",
|
||||||
|
}
|
||||||
|
for page, want := range cases {
|
||||||
|
if got := Gallery("me", page); got != want {
|
||||||
|
t.Errorf("Gallery(me, %d) = %q; want %q", page, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMsgSubmissionsCursor(t *testing.T) {
|
||||||
|
got := MsgSubmissionsCursor(65032289)
|
||||||
|
want := "https://www.furaffinity.net/msg/submissions/new~65032289@72/"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("MsgSubmissionsCursor(65032289) = %q; want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAbsoluteCDN(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"": "",
|
||||||
|
"https://d.example/x.png": "https://d.example/x.png",
|
||||||
|
"http://d.example/x.png": "http://d.example/x.png",
|
||||||
|
"//d.furaffinity.net/art/x.png": "https://d.furaffinity.net/art/x.png",
|
||||||
|
"/view/1/": "https://www.furaffinity.net/view/1/",
|
||||||
|
"art/foo": "https://www.furaffinity.net/art/foo",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := AbsoluteCDN(in); got != want {
|
||||||
|
t.Errorf("AbsoluteCDN(%q) = %q; want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
80
journal.go
Normal file
80
journal.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"iter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Journal is a single journal entry, as seen on /journal/{id}/.
|
||||||
|
type Journal struct {
|
||||||
|
ID JournalID
|
||||||
|
Title string
|
||||||
|
Author UserRef
|
||||||
|
PostedAt time.Time
|
||||||
|
BodyHTML string
|
||||||
|
BodyText string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJournal fetches a journal entry by its numeric ID.
|
||||||
|
func (c *Client) GetJournal(ctx context.Context, id JournalID) (*Journal, error) {
|
||||||
|
if id <= 0 {
|
||||||
|
return nil, fmt.Errorf("fa: GetJournal: id must be > 0")
|
||||||
|
}
|
||||||
|
var out *Journal
|
||||||
|
err := c.fetch(ctx, urls.Journal(int64(id)), func(doc *goquery.Document) error {
|
||||||
|
j, err := parseJournal(id, doc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = j
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserJournals iterates a user's journals across pages. The iterator yields
|
||||||
|
// each [Journal] preview (full body included on FA's listing page).
|
||||||
|
//
|
||||||
|
// Use [ListOptions.MaxPages] to bound the crawl.
|
||||||
|
func (c *Client) UserJournals(ctx context.Context, name string, opts ListOptions) iter.Seq2[*Journal, error] {
|
||||||
|
return func(yield func(*Journal, error) bool) {
|
||||||
|
page := opts.firstPage()
|
||||||
|
pagesFetched := 0
|
||||||
|
for {
|
||||||
|
if opts.reachedLimit(pagesFetched) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
items []*Journal
|
||||||
|
hasNext bool
|
||||||
|
)
|
||||||
|
err := c.fetch(ctx, urls.UserJournals(name, page), func(doc *goquery.Document) error {
|
||||||
|
items, hasNext = parseUserJournalsPage(doc)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
yield(nil, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pagesFetched++
|
||||||
|
for _, j := range items {
|
||||||
|
if !yield(j, nil) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasNext {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
journal_parser.go
Normal file
96
journal_parser.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseJournal lifts a [Journal] from a /journal/{id}/ document. FA renders
|
||||||
|
// the journal view inside the author's profile shell, so the author is
|
||||||
|
// derived from the userpage nav rather than from any byline in the journal
|
||||||
|
// body itself.
|
||||||
|
func parseJournal(id JournalID, doc *goquery.Document) (*Journal, error) {
|
||||||
|
j := &Journal{ID: id}
|
||||||
|
|
||||||
|
// Title.
|
||||||
|
j.Title = trimText(doc.Find("#c-journalTitleTop__subject h3").First())
|
||||||
|
if j.Title == "" {
|
||||||
|
j.Title = firstNonEmpty(
|
||||||
|
trimText(doc.Find("h2.journal-title").First()),
|
||||||
|
trimText(doc.Find("h3.journal-title").First()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if j.Title == "" {
|
||||||
|
return nil, fmt.Errorf("%w: journal %d: missing title", ErrParse, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Author from the userpage nav at the top of the rendered page.
|
||||||
|
authorLink := doc.Find("a.c-usernameBlock__displayName[href^='/user/']").First()
|
||||||
|
if authorLink.Length() > 0 {
|
||||||
|
href, _ := authorLink.Attr("href")
|
||||||
|
j.Author = UserRef{
|
||||||
|
DisplayName: trimText(authorLink.Find("span.js-displayName").First()),
|
||||||
|
AvatarURL: urls.AbsoluteCDN(trimAttr(doc.Find("img.user-nav-avatar").First(), "src")),
|
||||||
|
}
|
||||||
|
if j.Author.DisplayName == "" {
|
||||||
|
j.Author.DisplayName = trimText(authorLink)
|
||||||
|
}
|
||||||
|
if parts := strings.Split(strings.Trim(href, "/"), "/"); len(parts) >= 2 {
|
||||||
|
j.Author.Name = strings.ToLower(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date from the journal title block.
|
||||||
|
j.PostedAt = parsePopupDate(doc.Find("#c-journalTitleTop__date span.popup_date").First())
|
||||||
|
if j.PostedAt.IsZero() {
|
||||||
|
j.PostedAt = parsePopupDate(doc.Find("span.popup_date").First())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body.
|
||||||
|
body := firstNonEmptySel(doc,
|
||||||
|
"div.section-body.journal-body-theme div.journal-content",
|
||||||
|
"div.journal-content",
|
||||||
|
"div.journal-body",
|
||||||
|
"section.journal-body",
|
||||||
|
)
|
||||||
|
if body != nil {
|
||||||
|
j.BodyHTML = htmlOf(body)
|
||||||
|
j.BodyText = strings.TrimSpace(body.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
return j, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUserJournalsPage parses a /journals/{user}/[page]/ listing page,
|
||||||
|
// returning the journal entries it contains and whether a next page exists.
|
||||||
|
//
|
||||||
|
// FA renders each entry inside the listing differently from the standalone
|
||||||
|
// journal page; selectors here target the listing's tile structure.
|
||||||
|
func parseUserJournalsPage(doc *goquery.Document) (entries []*Journal, hasNext bool) {
|
||||||
|
doc.Find("section.journal, section[id^=jid], div.journal[id^=jid]").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
j := &Journal{}
|
||||||
|
idAttr, _ := sel.Attr("id")
|
||||||
|
idAttr = strings.TrimPrefix(idAttr, "jid:")
|
||||||
|
idAttr = strings.TrimPrefix(idAttr, "journal-")
|
||||||
|
if n, err := parseID[JournalID](strings.TrimSpace(idAttr)); err == nil {
|
||||||
|
j.ID = n
|
||||||
|
}
|
||||||
|
j.Title = firstNonEmpty(
|
||||||
|
trimText(sel.Find("h2 a, h3 a").First()),
|
||||||
|
trimText(sel.Find("h2, h3").First()),
|
||||||
|
)
|
||||||
|
j.PostedAt = parsePopupDate(sel.Find("span.popup_date").First())
|
||||||
|
body := sel.Find("div.journal-body, div.journal-content").First()
|
||||||
|
j.BodyHTML = htmlOf(body)
|
||||||
|
j.BodyText = strings.TrimSpace(body.Text())
|
||||||
|
if j.ID != 0 || j.Title != "" {
|
||||||
|
entries = append(entries, j)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
hasNext = detectNextPage(doc)
|
||||||
|
return entries, hasNext
|
||||||
|
}
|
||||||
103
journal_parser_test.go
Normal file
103
journal_parser_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
)
|
||||||
|
|
||||||
|
const syntheticJournalHTML = `<html><body>
|
||||||
|
<a class="c-usernameBlock__displayName js-displayName-block" href="/user/jjwriter/">
|
||||||
|
<span class="js-displayName">JJWriter</span>
|
||||||
|
</a>
|
||||||
|
<img class="user-nav-avatar" src="//d.example/avatars/jjwriter.png"/>
|
||||||
|
<div id="c-journalTitleTop">
|
||||||
|
<span id="c-journalTitleTop__subject"><h3>My Journal Entry</h3></span>
|
||||||
|
</div>
|
||||||
|
<div id="c-journalTitleBottom">
|
||||||
|
<span id="c-journalTitleTop__date"><span class="popup_date" data-time="1743851460" title="April 5, 2025 11:11:00 AM">Apr 5</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body journal-body-theme">
|
||||||
|
<div class="journal-content user-submitted-links"><p>Some <i>thoughts</i>.</p></div>
|
||||||
|
</div>
|
||||||
|
</body></html>`
|
||||||
|
|
||||||
|
func TestParseJournal_Synthetic(t *testing.T) {
|
||||||
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(syntheticJournalHTML))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
j, err := parseJournal(42, doc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseJournal: %v", err)
|
||||||
|
}
|
||||||
|
if j.ID != 42 {
|
||||||
|
t.Errorf("ID = %d; want 42", j.ID)
|
||||||
|
}
|
||||||
|
if j.Title != "My Journal Entry" {
|
||||||
|
t.Errorf("Title = %q", j.Title)
|
||||||
|
}
|
||||||
|
if j.Author.Name != "jjwriter" {
|
||||||
|
t.Errorf("Author.Name = %q", j.Author.Name)
|
||||||
|
}
|
||||||
|
if j.Author.DisplayName != "JJWriter" {
|
||||||
|
t.Errorf("Author.DisplayName = %q", j.Author.DisplayName)
|
||||||
|
}
|
||||||
|
if j.PostedAt.Year() != 2025 {
|
||||||
|
t.Errorf("PostedAt year = %d", j.PostedAt.Year())
|
||||||
|
}
|
||||||
|
if !strings.Contains(j.BodyText, "thoughts") {
|
||||||
|
t.Errorf("BodyText missing: %q", j.BodyText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const syntheticUserJournalsHTML = `<html><body>
|
||||||
|
<section id="jid:111" class="journal">
|
||||||
|
<h2><a href="/journal/111/">First Entry</a></h2>
|
||||||
|
<span class="popup_date" title="Apr 1, 2026 10:00 AM">today</span>
|
||||||
|
<div class="journal-body">Hello.</div>
|
||||||
|
</section>
|
||||||
|
<section id="jid:222" class="journal">
|
||||||
|
<h2><a href="/journal/222/">Second Entry</a></h2>
|
||||||
|
<span class="popup_date" title="Mar 30, 2026 09:00 AM">yesterday</span>
|
||||||
|
<div class="journal-body">World.</div>
|
||||||
|
</section>
|
||||||
|
<a class="button standard" href="/journals/me/2/">Next</a>
|
||||||
|
</body></html>`
|
||||||
|
|
||||||
|
func TestParseUserJournalsPage_Synthetic(t *testing.T) {
|
||||||
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(syntheticUserJournalsHTML))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
entries, hasNext := parseUserJournalsPage(doc)
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("entries = %d; want 2", len(entries))
|
||||||
|
}
|
||||||
|
if entries[0].ID != 111 || entries[1].ID != 222 {
|
||||||
|
t.Errorf("ids = [%d, %d]; want [111, 222]", entries[0].ID, entries[1].ID)
|
||||||
|
}
|
||||||
|
if entries[0].Title != "First Entry" {
|
||||||
|
t.Errorf("title[0] = %q", entries[0].Title)
|
||||||
|
}
|
||||||
|
if !hasNext {
|
||||||
|
t.Error("hasNext = false; want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseJournal_RealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "journal.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
j, err := parseJournal(0, doc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseJournal(real): %v", err)
|
||||||
|
}
|
||||||
|
if j.Title == "" {
|
||||||
|
t.Error("real fixture: Title is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
68
listing_json.go
Normal file
68
listing_json.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// listingJSONEntry is one row of FA's embedded js-submissionData blob —
|
||||||
|
// a JSON dictionary every listing page emits with stable metadata for
|
||||||
|
// each visible submission. The blob is keyed by submission ID (string)
|
||||||
|
// and survives most HTML reshuffles because it's consumed by FA's own
|
||||||
|
// front-end JS.
|
||||||
|
//
|
||||||
|
// Not every field appears on every page; description is sometimes truncated
|
||||||
|
// to ~500 chars with a trailing "…". We use this purely as a hinting
|
||||||
|
// source the HTML figure scrape remains authoritative for fields the
|
||||||
|
// JSON doesn't carry (ID, rating, thumbnail).
|
||||||
|
type listingJSONEntry struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"` // BBCode source (may be truncated)
|
||||||
|
Username string `json:"username"` // display name with original casing
|
||||||
|
Lower string `json:"lower"` // URL-safe lowercase login
|
||||||
|
AvatarMtime string `json:"avatar_mtime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// listingJSONMap is the parsed js-submissionData blob: SubmissionID → entry.
|
||||||
|
type listingJSONMap map[SubmissionID]listingJSONEntry
|
||||||
|
|
||||||
|
// readListingJSON pulls and parses the <script id="js-submissionData">
|
||||||
|
// blob from doc. Returns nil if the tag is absent or the JSON is
|
||||||
|
// malformed the caller then falls back to HTML-only scraping. The
|
||||||
|
// JSON is keyed by string in the source; we deserialize directly into a
|
||||||
|
// SubmissionID map by exploiting the typed int unmarshal path.
|
||||||
|
func readListingJSON(doc *goquery.Document) listingJSONMap {
|
||||||
|
raw := strings.TrimSpace(doc.Find("script#js-submissionData").First().Text())
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Decode into string-keyed map first because Go's json package only
|
||||||
|
// accepts string-convertible keys from JSON object keys.
|
||||||
|
var stringKeyed map[string]listingJSONEntry
|
||||||
|
if err := json.Unmarshal([]byte(raw), &stringKeyed); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(listingJSONMap, len(stringKeyed))
|
||||||
|
for k, v := range stringKeyed {
|
||||||
|
id, err := parseID[SubmissionID](k)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[id] = v
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// avatarURLFromMtime builds FA's avatar CDN URL from the mtime + lowercase
|
||||||
|
// login. Returns "" when either piece is missing.
|
||||||
|
func avatarURLFromMtime(lower, mtime string) string {
|
||||||
|
if lower == "" || mtime == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "https://a.furaffinity.net/" + mtime + "/" + lower + ".gif"
|
||||||
|
}
|
||||||
151
listing_json_test.go
Normal file
151
listing_json_test.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestReadListingJSON_RealFixture parses the embedded JSON blob out of the
|
||||||
|
// captured gallery_page1.html and asserts the shape we expect: keyed by
|
||||||
|
// SubmissionID, populated title/username/lower fields.
|
||||||
|
func TestReadListingJSON_RealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "gallery_page1.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
m := readListingJSON(doc)
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("readListingJSON returned nil script tag missing or malformed?")
|
||||||
|
}
|
||||||
|
if len(m) == 0 {
|
||||||
|
t.Fatal("readListingJSON returned empty map")
|
||||||
|
}
|
||||||
|
t.Logf("parsed %d entries", len(m))
|
||||||
|
|
||||||
|
missingTitle, missingLower, missingMtime := 0, 0, 0
|
||||||
|
for id, entry := range m {
|
||||||
|
if id == 0 {
|
||||||
|
t.Errorf("entry has zero ID")
|
||||||
|
}
|
||||||
|
if entry.Title == "" {
|
||||||
|
missingTitle++
|
||||||
|
}
|
||||||
|
if entry.Lower == "" {
|
||||||
|
missingLower++
|
||||||
|
}
|
||||||
|
if entry.AvatarMtime == "" {
|
||||||
|
missingMtime++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Allow some entries to lack avatar_mtime (FA sometimes omits it for
|
||||||
|
// stale avatars), but title/lower should be on every entry.
|
||||||
|
if missingTitle > 0 {
|
||||||
|
t.Errorf("%d/%d entries had empty Title", missingTitle, len(m))
|
||||||
|
}
|
||||||
|
if missingLower > 0 {
|
||||||
|
t.Errorf("%d/%d entries had empty Lower", missingLower, len(m))
|
||||||
|
}
|
||||||
|
t.Logf("missing avatar_mtime: %d/%d (acceptable)", missingMtime, len(m))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseGalleryPage_JSONMergePicksUpAvatars compares HTML-only and JSON
|
||||||
|
// merge modes on a favorites fixture (favorites lists submissions by many
|
||||||
|
// different artists, so the embedded JSON carries avatar_mtime for each;
|
||||||
|
// gallery pages don't, since every item is by the same artist).
|
||||||
|
//
|
||||||
|
// The JSON path should populate Author.AvatarURL on items where HTML
|
||||||
|
// scraping leaves it empty.
|
||||||
|
func TestParseGalleryPage_JSONMergePicksUpAvatars(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "favorites_page1.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlOnly, _ := parseGalleryPage(doc, false)
|
||||||
|
jsonMerged, _ := parseGalleryPage(doc, true)
|
||||||
|
|
||||||
|
if len(htmlOnly) != len(jsonMerged) {
|
||||||
|
t.Fatalf("item counts differ: html=%d json=%d", len(htmlOnly), len(jsonMerged))
|
||||||
|
}
|
||||||
|
if len(jsonMerged) == 0 {
|
||||||
|
t.Fatal("no items parsed")
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlAvatars, jsonAvatars := 0, 0
|
||||||
|
for _, s := range htmlOnly {
|
||||||
|
if s.Author.AvatarURL != "" {
|
||||||
|
htmlAvatars++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range jsonMerged {
|
||||||
|
if s.Author.AvatarURL != "" {
|
||||||
|
jsonAvatars++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("html-only avatars: %d/%d; json-merged avatars: %d/%d",
|
||||||
|
htmlAvatars, len(htmlOnly), jsonAvatars, len(jsonMerged))
|
||||||
|
|
||||||
|
if jsonAvatars <= htmlAvatars {
|
||||||
|
t.Errorf("expected JSON-merged parse to populate more AvatarURLs than HTML-only; got html=%d json=%d",
|
||||||
|
htmlAvatars, jsonAvatars)
|
||||||
|
}
|
||||||
|
// Spot-check one avatar URL shape.
|
||||||
|
for _, s := range jsonMerged {
|
||||||
|
if s.Author.AvatarURL != "" {
|
||||||
|
if !strings.HasPrefix(s.Author.AvatarURL, "https://a.furaffinity.net/") {
|
||||||
|
t.Errorf("avatar URL has wrong prefix: %q", s.Author.AvatarURL)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(s.Author.AvatarURL, "/"+s.Author.Name+".gif") {
|
||||||
|
t.Errorf("avatar URL doesn't end with /%s.gif: %q", s.Author.Name, s.Author.AvatarURL)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseGalleryPage_JSONMergeGracefullyFallsBack confirms that
|
||||||
|
// useJSON=true on a doc with no js-submissionData blob produces the same
|
||||||
|
// result as useJSON=false i.e. the merge is non-destructive.
|
||||||
|
func TestParseGalleryPage_JSONMergeGracefullyFallsBack(t *testing.T) {
|
||||||
|
// The synthetic gallery fixture has no js-submissionData tag.
|
||||||
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(syntheticGalleryHTML))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
htmlOnly, _ := parseGalleryPage(doc, false)
|
||||||
|
jsonMerged, _ := parseGalleryPage(doc, true)
|
||||||
|
|
||||||
|
if len(htmlOnly) != len(jsonMerged) {
|
||||||
|
t.Fatalf("counts differ: %d vs %d", len(htmlOnly), len(jsonMerged))
|
||||||
|
}
|
||||||
|
for i := range htmlOnly {
|
||||||
|
if htmlOnly[i].ID != jsonMerged[i].ID || htmlOnly[i].Title != jsonMerged[i].Title {
|
||||||
|
t.Errorf("item %d: html=%+v json=%+v", i, htmlOnly[i], jsonMerged[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadListingJSON_MissingTagReturnsNil makes the fallback path
|
||||||
|
// explicit in a unit-style assertion.
|
||||||
|
func TestReadListingJSON_MissingTagReturnsNil(t *testing.T) {
|
||||||
|
doc, _ := goquery.NewDocumentFromReader(strings.NewReader("<html><body></body></html>"))
|
||||||
|
if m := readListingJSON(doc); m != nil {
|
||||||
|
t.Errorf("expected nil, got %d entries", len(m))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadListingJSON_MalformedJSONReturnsNil makes the fallback path
|
||||||
|
// explicit when FA's blob is present but somehow unparseable.
|
||||||
|
func TestReadListingJSON_MalformedJSONReturnsNil(t *testing.T) {
|
||||||
|
doc, _ := goquery.NewDocumentFromReader(strings.NewReader(
|
||||||
|
`<html><body><script id="js-submissionData" type="application/json">{not valid</script></body></html>`,
|
||||||
|
))
|
||||||
|
if m := readListingJSON(doc); m != nil {
|
||||||
|
t.Errorf("expected nil, got %d entries", len(m))
|
||||||
|
}
|
||||||
|
}
|
||||||
72
models.go
Normal file
72
models.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// UserRef is a lightweight reference to a user that appears next to other
|
||||||
|
// entities (a submission's author, a comment's author, etc.). Use [Client.GetUser]
|
||||||
|
// to load the full [User].
|
||||||
|
type UserRef struct {
|
||||||
|
// Name is the URL-safe login name FA uses for routing. Always lowercase.
|
||||||
|
Name string
|
||||||
|
// DisplayName preserves the user's chosen capitalisation and styling.
|
||||||
|
DisplayName string
|
||||||
|
// AvatarURL points to the user's avatar image on the CDN.
|
||||||
|
AvatarURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmissionRef is a thin reference to another submission, used for things
|
||||||
|
// like a user's featured submission preview where we only want a handful of
|
||||||
|
// fields without paying for a full submission fetch.
|
||||||
|
type SubmissionRef struct {
|
||||||
|
ID SubmissionID
|
||||||
|
Title string
|
||||||
|
ThumbURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FolderRef identifies a gallery folder a submission appears in.
|
||||||
|
type FolderRef struct {
|
||||||
|
Name string
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmissionStats are the headline counts shown in the submission sidebar.
|
||||||
|
type SubmissionStats struct {
|
||||||
|
Views int
|
||||||
|
Favorites int
|
||||||
|
Comments int
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserStats are the per-user counts shown on the profile page.
|
||||||
|
type UserStats struct {
|
||||||
|
Submissions int
|
||||||
|
Favorites int
|
||||||
|
Views int
|
||||||
|
Comments int
|
||||||
|
Journals int
|
||||||
|
Watchers int
|
||||||
|
Watching int
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserContact is one row of the "Contact Information" table on a profile,
|
||||||
|
// e.g. {Site: "Twitter", Handle: "@someone", URL: "https://twitter.com/someone"}.
|
||||||
|
type UserContact struct {
|
||||||
|
Site string
|
||||||
|
Handle string
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shout is one entry from the shouts/guestbook on a user's profile.
|
||||||
|
type Shout struct {
|
||||||
|
Author UserRef
|
||||||
|
PostedAt time.Time
|
||||||
|
BodyHTML string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiteBanner is the image rendered in the page header of a user's profile.
|
||||||
|
// FA always shows one: either the artist's own banner uploaded via
|
||||||
|
// /controls/profilebanner/, or if none is set FA's site-wide promo
|
||||||
|
// banner. IsCustom distinguishes the two.
|
||||||
|
type SiteBanner struct {
|
||||||
|
ImageURL string // absolute CDN URL of the banner image
|
||||||
|
IsCustom bool // true when the artist set their own banner
|
||||||
|
}
|
||||||
107
notes.go
Normal file
107
notes.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"iter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NoteID identifies a private message (note) thread on FA.
|
||||||
|
type NoteID int64
|
||||||
|
|
||||||
|
// NotePreview is a row from the notes inbox listing at /msg/pms/. It carries
|
||||||
|
// just enough to identify the thread and decide whether to fetch the full
|
||||||
|
// note via [Client.GetNote].
|
||||||
|
type NotePreview struct {
|
||||||
|
ID NoteID
|
||||||
|
Subject string
|
||||||
|
Sender UserRef // from-user as it appears in the inbox list
|
||||||
|
SentAt time.Time
|
||||||
|
Unread bool
|
||||||
|
ThreadURL string // the /msg/pms/1/{id}/#message link FA renders
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note is a single private message thread, as rendered at /viewmessage/{id}/.
|
||||||
|
// Subject and Body are the headline note; FA shows quoted prior replies
|
||||||
|
// inline within Body rather than as a separate thread, so callers wanting
|
||||||
|
// to programmatically split a conversation will need to walk Body's HTML.
|
||||||
|
type Note struct {
|
||||||
|
ID NoteID
|
||||||
|
Subject string
|
||||||
|
From UserRef
|
||||||
|
To UserRef
|
||||||
|
SentAt time.Time
|
||||||
|
BodyHTML string
|
||||||
|
BodyText string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notes iterates the private-message inbox at /msg/pms/. Each yielded
|
||||||
|
// [*NotePreview] is one thread; call [Client.GetNote] with its ID to load
|
||||||
|
// the body.
|
||||||
|
//
|
||||||
|
// FA paginates /msg/pms/ with a similar messagecenter-navigation control
|
||||||
|
// to the submission inbox.
|
||||||
|
//
|
||||||
|
// ListOptions.StartPage is ignored the inbox uses cursor pagination
|
||||||
|
// (follow-the-Next-link), not page-numbered fetches.
|
||||||
|
//
|
||||||
|
// Requires a logged-in client.
|
||||||
|
func (c *Client) Notes(ctx context.Context, opts ListOptions) iter.Seq2[*NotePreview, error] {
|
||||||
|
return func(yield func(*NotePreview, error) bool) {
|
||||||
|
nextURL := urls.MsgPMs()
|
||||||
|
pagesFetched := 0
|
||||||
|
for nextURL != "" {
|
||||||
|
if opts.reachedLimit(pagesFetched) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
items []*NotePreview
|
||||||
|
next string
|
||||||
|
)
|
||||||
|
err := c.fetch(ctx, nextURL, func(doc *goquery.Document) error {
|
||||||
|
items, next = parseNotesInboxPage(doc)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
yield(nil, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pagesFetched++
|
||||||
|
for _, it := range items {
|
||||||
|
if !yield(it, nil) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if next == "" || len(items) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nextURL = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNote fetches a single note (private message) by ID. Requires a
|
||||||
|
// logged-in client.
|
||||||
|
func (c *Client) GetNote(ctx context.Context, id NoteID) (*Note, error) {
|
||||||
|
if id <= 0 {
|
||||||
|
return nil, fmt.Errorf("fa: GetNote: id must be > 0")
|
||||||
|
}
|
||||||
|
var out *Note
|
||||||
|
err := c.fetch(ctx, urls.ViewMessage(int64(id)), func(doc *goquery.Document) error {
|
||||||
|
n, err := parseNote(id, doc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = n
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
133
notes_parser.go
Normal file
133
notes_parser.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseNotesInboxPage parses one page of the /msg/pms/ inbox listing,
|
||||||
|
// returning the previews and the absolute URL of the next page (or "" if
|
||||||
|
// there's no further page).
|
||||||
|
//
|
||||||
|
// Beta theme renders each thread as a <div class="c-noteListItem"> with:
|
||||||
|
// - subject link <a class="notelink"> whose href encodes the note id
|
||||||
|
// (/msg/pms/{folder}/{noteID}/#message)
|
||||||
|
// - sender block <div class="note-list-sender"> with a c-usernameBlock
|
||||||
|
// - send date <div class="note-list-senddate"> with a popup_date span
|
||||||
|
// - read-state class "note-read" / "note-unread" on the subject link
|
||||||
|
func parseNotesInboxPage(doc *goquery.Document) (items []*NotePreview, nextURL string) {
|
||||||
|
doc.Find("div#notes-list div.c-noteListItem").Each(func(_ int, item *goquery.Selection) {
|
||||||
|
np := parseNoteListItem(item)
|
||||||
|
if np != nil {
|
||||||
|
items = append(items, np)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pagination control on the message center.
|
||||||
|
if next := doc.Find("div.messagecenter-navigation a.button.more").First(); next.Length() > 0 {
|
||||||
|
href, _ := next.Attr("href")
|
||||||
|
nextURL = urls.AbsoluteCDN(href)
|
||||||
|
}
|
||||||
|
return items, nextURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNoteListItem lifts one <div class="c-noteListItem"> row.
|
||||||
|
func parseNoteListItem(item *goquery.Selection) *NotePreview {
|
||||||
|
subjectLink := item.Find("a.notelink").First()
|
||||||
|
if subjectLink.Length() == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
href, _ := subjectLink.Attr("href")
|
||||||
|
|
||||||
|
np := &NotePreview{
|
||||||
|
Subject: trimText(subjectLink.Find(".c-noteListItem__subject").First()),
|
||||||
|
ThreadURL: urls.AbsoluteCDN(href),
|
||||||
|
}
|
||||||
|
if np.Subject == "" {
|
||||||
|
np.Subject = trimText(subjectLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note ID lives in the href: /msg/pms/{folder}/{id}/#message. Strip the
|
||||||
|
// fragment first so extractIntFromHref picks the trailing numeric path.
|
||||||
|
if i := strings.Index(href, "#"); i != -1 {
|
||||||
|
href = href[:i]
|
||||||
|
}
|
||||||
|
np.ID = NoteID(extractIntFromHref(href))
|
||||||
|
|
||||||
|
// Read/unread: classes on the subject link.
|
||||||
|
if class, _ := subjectLink.Attr("class"); strings.Contains(class, "note-unread") || strings.Contains(class, "unread") && !strings.Contains(class, "note-read") {
|
||||||
|
np.Unread = true
|
||||||
|
}
|
||||||
|
|
||||||
|
senderBox := item.Find("div.note-list-sender")
|
||||||
|
np.Sender = userRefFromUsernameBlock(senderBox)
|
||||||
|
// FA marks notes from removed accounts with <span class="user-name-deleted">.
|
||||||
|
// In that case there is no usernameBlock and Name stays empty by design;
|
||||||
|
// surface the visible "[deleted]" string in DisplayName so callers can
|
||||||
|
// distinguish "no sender info" from "sender's account is gone".
|
||||||
|
if np.Sender.Name == "" && np.Sender.DisplayName == "" {
|
||||||
|
if deleted := senderBox.Find("span.user-name-deleted").First(); deleted.Length() > 0 {
|
||||||
|
np.Sender = UserRef{DisplayName: trimText(deleted)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
np.SentAt = parsePopupDate(item.Find("div.note-list-senddate span.popup_date").First())
|
||||||
|
return np
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNote lifts a single private-message thread from /viewmessage/{id}/.
|
||||||
|
//
|
||||||
|
// FA renders the note inside a <section> whose section-header contains the
|
||||||
|
// avatar, subject (<h2>), sender block, sent date, and recipient block.
|
||||||
|
// The body lives in the following <div class="section-body"> wrapped in a
|
||||||
|
// .user-submitted-links div.
|
||||||
|
func parseNote(id NoteID, doc *goquery.Document) (*Note, error) {
|
||||||
|
n := &Note{ID: id}
|
||||||
|
|
||||||
|
header := doc.Find("div.message-center-note-information.addresses").First()
|
||||||
|
if header.Length() == 0 {
|
||||||
|
// Older / alternative layout: try the parent block.
|
||||||
|
header = doc.Find("div.message-center-note-information").First()
|
||||||
|
}
|
||||||
|
|
||||||
|
n.Subject = trimText(header.Find("h2").First())
|
||||||
|
if n.Subject == "" {
|
||||||
|
return nil, fmt.Errorf("%w: note %d: missing subject", ErrParse, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender + recipient: the first and second c-usernameBlock inside the
|
||||||
|
// header, in document order (FA writes "Sent by … To …" sequentially).
|
||||||
|
blocks := header.Find("div.c-usernameBlock")
|
||||||
|
if blocks.Length() >= 1 {
|
||||||
|
n.From = userRefFromUsernameBlock(blocks.Eq(0))
|
||||||
|
}
|
||||||
|
if blocks.Length() >= 2 {
|
||||||
|
n.To = userRefFromUsernameBlock(blocks.Eq(1))
|
||||||
|
}
|
||||||
|
// Avatar lives in a sibling div within the surrounding container.
|
||||||
|
avatarSrc := trimAttr(doc.Find("div.message-center-note-information.avatar img.avatar").First(), "src")
|
||||||
|
if avatarSrc == "" {
|
||||||
|
avatarSrc = trimAttr(doc.Find("div.message-center-note-information img").First(), "src")
|
||||||
|
}
|
||||||
|
if avatarSrc != "" && n.From.AvatarURL == "" {
|
||||||
|
n.From.AvatarURL = urls.AbsoluteCDN(avatarSrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
n.SentAt = parsePopupDate(header.Find("span.popup_date").First())
|
||||||
|
|
||||||
|
// Body. FA wraps it in section .section-body > .user-submitted-links and
|
||||||
|
// occasionally prepends a scam-warning div which we strip from the
|
||||||
|
// plaintext convenience field but leave intact in the raw HTML.
|
||||||
|
body := doc.Find("section div.section-body div.user-submitted-links").First()
|
||||||
|
if body.Length() == 0 {
|
||||||
|
body = doc.Find("section div.section-body").First()
|
||||||
|
}
|
||||||
|
n.BodyHTML = htmlOf(body)
|
||||||
|
bodyTextSel := body.Clone()
|
||||||
|
bodyTextSel.Find(".noteWarningMessage").Remove()
|
||||||
|
n.BodyText = strings.TrimSpace(bodyTextSel.Text())
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
73
notes_send.go
Normal file
73
notes_send.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SendNote sends a new private message (note) to a user. Requires login.
|
||||||
|
//
|
||||||
|
// FA's note form posts to /msg/send/ with a per-form CSRF key that must
|
||||||
|
// be scraped from the notes inbox (or any page that renders the send
|
||||||
|
// form). This method does the scrape + post in one call.
|
||||||
|
//
|
||||||
|
// Returns nil on success. ErrUnauthorized when not logged in.
|
||||||
|
// *SystemMessageError when FA rejects the send (recipient blocked, rate
|
||||||
|
// limited, etc.).
|
||||||
|
func (c *Client) SendNote(ctx context.Context, to string, subject string, body string) error {
|
||||||
|
to = strings.TrimSpace(to)
|
||||||
|
if to == "" {
|
||||||
|
return fmt.Errorf("fa: SendNote: empty recipient")
|
||||||
|
}
|
||||||
|
if subject == "" {
|
||||||
|
return fmt.Errorf("fa: SendNote: empty subject")
|
||||||
|
}
|
||||||
|
if body == "" {
|
||||||
|
return fmt.Errorf("fa: SendNote: empty body")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrape the form key from /msg/pms/. The notes inbox renders the same
|
||||||
|
// CSRF key in any note-related form on the page; we don't actually need
|
||||||
|
// to be replying to anything to harvest it.
|
||||||
|
var key string
|
||||||
|
err := c.fetch(ctx, urls.MsgPMs(), func(doc *goquery.Document) error {
|
||||||
|
key = findNoteKey(doc)
|
||||||
|
if key == "" {
|
||||||
|
return fmt.Errorf("%w: SendNote: could not locate form key on /msg/pms/", ErrUnauthorized)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
v := url.Values{}
|
||||||
|
v.Set("key", key)
|
||||||
|
v.Set("to", to)
|
||||||
|
v.Set("subject", subject)
|
||||||
|
v.Set("message", body)
|
||||||
|
v.Set("send", "Send Note")
|
||||||
|
_, err = c.postForm(ctx, urls.Host+"/msg/send/", v)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// findNoteKey looks for a hidden input named "key" inside any form that
|
||||||
|
// targets the notes subsystem (/msg/send/ or /msg/pms/). FA reuses the
|
||||||
|
// same key value across these forms within a session.
|
||||||
|
func findNoteKey(doc *goquery.Document) string {
|
||||||
|
var key string
|
||||||
|
doc.Find("form[action='/msg/send/'] input[name='key'], form[action='/msg/pms/'] input[name='key'], form[action^='/msg/'] input[name='key']").EachWithBreak(func(_ int, sel *goquery.Selection) bool {
|
||||||
|
if v := strings.TrimSpace(trimAttr(sel, "value")); v != "" {
|
||||||
|
key = v
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return key
|
||||||
|
}
|
||||||
207
notifications.go
Normal file
207
notifications.go
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notifications is the parsed contents of /msg/others/ FA's catch-all
|
||||||
|
// notification page. Each category surfaces as a separate slice so callers
|
||||||
|
// don't have to type-switch over a heterogeneous list.
|
||||||
|
//
|
||||||
|
// Sections that aren't currently rendered (because you have none of that
|
||||||
|
// type pending) come back as nil/empty rather than as an error.
|
||||||
|
type Notifications struct {
|
||||||
|
Journals []JournalNotification
|
||||||
|
Watches []WatchNotification
|
||||||
|
SubmissionComments []CommentNotification
|
||||||
|
JournalComments []CommentNotification
|
||||||
|
Favorites []FavNotification
|
||||||
|
Shouts []ShoutNotification
|
||||||
|
}
|
||||||
|
|
||||||
|
// JournalNotification represents one entry in the "Journals" notification
|
||||||
|
// section a journal posted by someone you watch.
|
||||||
|
type JournalNotification struct {
|
||||||
|
JournalID JournalID
|
||||||
|
Title string
|
||||||
|
Author UserRef
|
||||||
|
Rating Rating
|
||||||
|
PostedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchNotification is a user that started watching you.
|
||||||
|
type WatchNotification struct {
|
||||||
|
User UserRef
|
||||||
|
WatchedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentNotification is a comment posted on one of your submissions or
|
||||||
|
// journals. OnSubmission is non-zero for submission comments;
|
||||||
|
// OnJournal is non-zero for journal comments. Only one is set per item;
|
||||||
|
// they're segregated into the two slices on [Notifications] but share this
|
||||||
|
// type for callers that want to merge.
|
||||||
|
type CommentNotification struct {
|
||||||
|
CommentID CommentID
|
||||||
|
OnSubmission SubmissionID
|
||||||
|
OnJournal JournalID
|
||||||
|
OnTitle string // title of the submission/journal that was commented on
|
||||||
|
Author UserRef
|
||||||
|
PostedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// FavNotification is someone favoriting one of your submissions.
|
||||||
|
type FavNotification struct {
|
||||||
|
SubmissionID SubmissionID
|
||||||
|
SubmissionTitle string
|
||||||
|
Favoriter UserRef
|
||||||
|
FavoritedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShoutNotification is a new shout left on your profile.
|
||||||
|
type ShoutNotification struct {
|
||||||
|
ShoutID int64 // FA's internal shout id (matches the anchor id="shout-N" on your profile)
|
||||||
|
Author UserRef
|
||||||
|
PostedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationsOption tunes a single [Client.Notifications] call.
|
||||||
|
type NotificationsOption func(*notificationsConfig)
|
||||||
|
|
||||||
|
type notificationsConfig struct {
|
||||||
|
resolveAvatars bool
|
||||||
|
avatarLimit int
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithResolvedAvatars makes [Client.Notifications] fill in author avatar
|
||||||
|
// URLs that FurAffinity omits from the /msg/others/ markup.
|
||||||
|
//
|
||||||
|
// FA does not render an avatar image on every notification row journal
|
||||||
|
// notifications in particular are a purely textual list, so their author
|
||||||
|
// UserRef comes back with AvatarURL == "". When this option is set, the
|
||||||
|
// SDK resolves authors by fetching each one's profile page and reading the
|
||||||
|
// avatar from it.
|
||||||
|
//
|
||||||
|
// Each distinct author costs one extra HTTP request, deduplicated within
|
||||||
|
// the call and serialized through the client's rate limiter. Because that
|
||||||
|
// limiter is global, total wall time is roughly (distinct authors) ×
|
||||||
|
// (rate interval) on a busy account, dozens of seconds. limit caps how
|
||||||
|
// many distinct authors are resolved: pass a small value (e.g. 12) to
|
||||||
|
// bound a cold load. Authors are resolved in notification order with
|
||||||
|
// journals first, so a Home "recent journals" feed still gets real
|
||||||
|
// avatars; any author past the limit keeps AvatarURL == "" (callers
|
||||||
|
// typically render an initials fallback). A limit <= 0 means unlimited.
|
||||||
|
//
|
||||||
|
// Per-author failures are ignored: a user whose page can't be fetched
|
||||||
|
// simply keeps an empty AvatarURL.
|
||||||
|
func WithResolvedAvatars(limit int) NotificationsOption {
|
||||||
|
return func(c *notificationsConfig) {
|
||||||
|
c.resolveAvatars = true
|
||||||
|
c.avatarLimit = limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifications fetches /msg/others/ and returns the parsed notification
|
||||||
|
// page. Requires a logged-in client; anonymous calls surface as
|
||||||
|
// [ErrUnauthorized].
|
||||||
|
//
|
||||||
|
// All categories are returned in a single fetch there is no pagination
|
||||||
|
// on this page. Pass [WithResolvedAvatars] to additionally backfill author
|
||||||
|
// avatars that FA omits from the page (see that option's docs).
|
||||||
|
func (c *Client) Notifications(ctx context.Context, opts ...NotificationsOption) (*Notifications, error) {
|
||||||
|
var cfg notificationsConfig
|
||||||
|
for _, o := range opts {
|
||||||
|
o(&cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out *Notifications
|
||||||
|
err := c.fetch(ctx, urls.MsgOthers(), func(doc *goquery.Document) error {
|
||||||
|
n, err := parseNotifications(doc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = n
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.resolveAvatars {
|
||||||
|
c.resolveNotificationAvatars(ctx, out, cfg.avatarLimit)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveNotificationAvatars backfills empty author AvatarURLs on a parsed
|
||||||
|
// [Notifications] by fetching each distinct author's profile page. Authors
|
||||||
|
// that already carry an avatar (FA does render one on, e.g., watch rows)
|
||||||
|
// are left untouched. Lookups are deduplicated by URL-safe name including
|
||||||
|
// negative results so each author costs at most one request.
|
||||||
|
//
|
||||||
|
// limit caps the number of distinct authors actually fetched; a limit <= 0
|
||||||
|
// is unlimited. Authors are visited journals-first, so when the budget is
|
||||||
|
// small the Home "recent journals" feed is the part that gets real avatars.
|
||||||
|
// An already-fetched author (cache hit) is still applied past the limit —
|
||||||
|
// that costs nothing only *new* fetches are gated.
|
||||||
|
//
|
||||||
|
// Failures are deliberately swallowed: a single unreachable profile must
|
||||||
|
// not fail the whole notifications call, and a stale ctx simply leaves the
|
||||||
|
// remaining avatars empty.
|
||||||
|
func (c *Client) resolveNotificationAvatars(ctx context.Context, n *Notifications, limit int) {
|
||||||
|
if n == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cache := make(map[string]string)
|
||||||
|
fill := func(ref *UserRef) {
|
||||||
|
if ref.Name == "" || ref.AvatarURL != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
avatar, ok := cache[ref.Name]
|
||||||
|
if !ok {
|
||||||
|
if limit > 0 && len(cache) >= limit {
|
||||||
|
return // fetch budget exhausted leave AvatarURL empty
|
||||||
|
}
|
||||||
|
avatar = c.fetchUserAvatar(ctx, ref.Name)
|
||||||
|
cache[ref.Name] = avatar
|
||||||
|
}
|
||||||
|
ref.AvatarURL = avatar
|
||||||
|
}
|
||||||
|
for i := range n.Journals {
|
||||||
|
fill(&n.Journals[i].Author)
|
||||||
|
}
|
||||||
|
for i := range n.Watches {
|
||||||
|
fill(&n.Watches[i].User)
|
||||||
|
}
|
||||||
|
for i := range n.SubmissionComments {
|
||||||
|
fill(&n.SubmissionComments[i].Author)
|
||||||
|
}
|
||||||
|
for i := range n.JournalComments {
|
||||||
|
fill(&n.JournalComments[i].Author)
|
||||||
|
}
|
||||||
|
for i := range n.Favorites {
|
||||||
|
fill(&n.Favorites[i].Favoriter)
|
||||||
|
}
|
||||||
|
for i := range n.Shouts {
|
||||||
|
fill(&n.Shouts[i].Author)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchUserAvatar fetches /user/{name}/ and returns just the profile
|
||||||
|
// owner's avatar URL. It returns "" on any failure callers treat a
|
||||||
|
// missing avatar as non-fatal.
|
||||||
|
func (c *Client) fetchUserAvatar(ctx context.Context, name string) string {
|
||||||
|
var avatar string
|
||||||
|
_ = c.fetch(ctx, urls.User(name), func(doc *goquery.Document) error {
|
||||||
|
avatar = urls.AbsoluteCDN(firstNonEmpty(
|
||||||
|
trimAttr(doc.Find("userpage-nav-avatar img").First(), "src"),
|
||||||
|
trimAttr(doc.Find("div.userpage-nav-avatar img").First(), "src"),
|
||||||
|
))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return avatar
|
||||||
|
}
|
||||||
222
notifications_parser.go
Normal file
222
notifications_parser.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseNotifications walks /msg/others/ and fills in every category that
|
||||||
|
// the page renders. Sections FA omits (because you have nothing pending of
|
||||||
|
// that type) result in nil slices, not errors.
|
||||||
|
//
|
||||||
|
// Each section uses the same outer shape `section.section_container#messages-X`
|
||||||
|
// with a `ul.message-stream > li` body. The per-row content differs, so
|
||||||
|
// each category has its own per-row helper below.
|
||||||
|
func parseNotifications(doc *goquery.Document) (*Notifications, error) {
|
||||||
|
n := &Notifications{}
|
||||||
|
|
||||||
|
doc.Find("section.section_container#messages-journals ul.message-stream > li").Each(func(_ int, li *goquery.Selection) {
|
||||||
|
if j := parseJournalNotification(li); j != nil {
|
||||||
|
n.Journals = append(n.Journals, *j)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
doc.Find("section.section_container#messages-watches ul.message-stream > li").Each(func(_ int, li *goquery.Selection) {
|
||||||
|
if w := parseWatchNotification(li); w != nil {
|
||||||
|
n.Watches = append(n.Watches, *w)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
doc.Find("section.section_container#messages-comments-submission ul.message-stream > li").Each(func(_ int, li *goquery.Selection) {
|
||||||
|
if c := parseCommentNotification(li, false); c != nil {
|
||||||
|
n.SubmissionComments = append(n.SubmissionComments, *c)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
doc.Find("section.section_container#messages-comments-journal ul.message-stream > li").Each(func(_ int, li *goquery.Selection) {
|
||||||
|
if c := parseCommentNotification(li, true); c != nil {
|
||||||
|
n.JournalComments = append(n.JournalComments, *c)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
doc.Find("section.section_container#messages-favorites ul.message-stream > li").Each(func(_ int, li *goquery.Selection) {
|
||||||
|
if f := parseFavNotification(li); f != nil {
|
||||||
|
n.Favorites = append(n.Favorites, *f)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
doc.Find("section.section_container#messages-shouts ul.message-stream > li").Each(func(_ int, li *goquery.Selection) {
|
||||||
|
if s := parseShoutNotification(li); s != nil {
|
||||||
|
n.Shouts = append(n.Shouts, *s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseJournalNotification lifts one row from the Journals section. Layout:
|
||||||
|
//
|
||||||
|
// <li>
|
||||||
|
// <div class="table">
|
||||||
|
// <div class="cell"><input ... value="{journalID}"></div>
|
||||||
|
// <div class="cell">
|
||||||
|
// <a href="/journal/{id}/"><em class="journal_subject">Title</em></a>
|
||||||
|
// (<span class="c-contentRating--general">G</span>)
|
||||||
|
// posted by <span class="c-usernameBlockSimple"><a href="/user/.../"><span class="c-usernameBlockSimple__displayName" title=" name ">Display</span></a></span>
|
||||||
|
// <span class="popup_date" data-time="...">timestamp</span>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// </li>
|
||||||
|
func parseJournalNotification(li *goquery.Selection) *JournalNotification {
|
||||||
|
link := li.Find("a[href^='/journal/']").First()
|
||||||
|
if link.Length() == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
href, _ := link.Attr("href")
|
||||||
|
id := JournalID(extractIntFromHref(href))
|
||||||
|
if id == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
j := &JournalNotification{
|
||||||
|
JournalID: id,
|
||||||
|
Title: trimText(link.Find("em.journal_subject").First()),
|
||||||
|
}
|
||||||
|
if j.Title == "" {
|
||||||
|
j.Title = trimText(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rating: <span class="c-contentRating--general">G</span>
|
||||||
|
if r := li.Find("span[class*='c-contentRating--']").First(); r.Length() > 0 {
|
||||||
|
j.Rating = ratingFromClass(trimAttr(r, "class"))
|
||||||
|
}
|
||||||
|
|
||||||
|
j.Author = userRefFromUsernameBlock(li)
|
||||||
|
j.PostedAt = parsePopupDate(li.Find("span.popup_date").First())
|
||||||
|
return j
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseWatchNotification: a user that started watching you. Typical layout
|
||||||
|
// is a <li> with the user's avatar link, display-name block, and date.
|
||||||
|
func parseWatchNotification(li *goquery.Selection) *WatchNotification {
|
||||||
|
w := &WatchNotification{User: userRefFromUsernameBlock(li)}
|
||||||
|
if w.User.Name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Avatar often lives in the same <li> as a separate <a><img/></a>.
|
||||||
|
if av := li.Find("img").First(); av.Length() > 0 {
|
||||||
|
w.User.AvatarURL = urls.AbsoluteCDN(trimAttr(av, "src"))
|
||||||
|
}
|
||||||
|
w.WatchedAt = parsePopupDate(li.Find("span.popup_date").First())
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCommentNotification: a comment on one of your submissions or journals.
|
||||||
|
// isJournal selects which target field to populate. Comment ID is on the
|
||||||
|
// link's #cid:N fragment or in a data attribute on the row.
|
||||||
|
func parseCommentNotification(li *goquery.Selection, isJournal bool) *CommentNotification {
|
||||||
|
// The link goes to /view/{id}/#cid:N or /journal/{id}/#cid:N
|
||||||
|
link := li.Find("a[href*='/view/'], a[href*='/journal/']").First()
|
||||||
|
if link.Length() == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
href, _ := link.Attr("href")
|
||||||
|
c := &CommentNotification{
|
||||||
|
OnTitle: trimText(link),
|
||||||
|
Author: userRefFromUsernameBlock(li),
|
||||||
|
}
|
||||||
|
// Pull the comment ID out of the URL fragment, e.g. "#cid:12345" or "#comment-12345".
|
||||||
|
if i := strings.Index(href, "#"); i != -1 {
|
||||||
|
frag := href[i+1:]
|
||||||
|
frag = strings.TrimPrefix(frag, "cid:")
|
||||||
|
frag = strings.TrimPrefix(frag, "comment-")
|
||||||
|
if n, err := parseID[CommentID](strings.TrimSpace(frag)); err == nil {
|
||||||
|
c.CommentID = n
|
||||||
|
}
|
||||||
|
href = href[:i] // strip fragment for ID parsing below
|
||||||
|
}
|
||||||
|
id := extractIntFromHref(href)
|
||||||
|
if isJournal {
|
||||||
|
c.OnJournal = JournalID(id)
|
||||||
|
} else {
|
||||||
|
c.OnSubmission = SubmissionID(id)
|
||||||
|
}
|
||||||
|
c.PostedAt = parsePopupDate(li.Find("span.popup_date").First())
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFavNotification: someone favorited one of your submissions.
|
||||||
|
func parseFavNotification(li *goquery.Selection) *FavNotification {
|
||||||
|
link := li.Find("a[href^='/view/']").First()
|
||||||
|
if link.Length() == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
href, _ := link.Attr("href")
|
||||||
|
f := &FavNotification{
|
||||||
|
SubmissionID: SubmissionID(extractIntFromHref(href)),
|
||||||
|
SubmissionTitle: trimText(link),
|
||||||
|
Favoriter: userRefFromUsernameBlock(li),
|
||||||
|
}
|
||||||
|
f.FavoritedAt = parsePopupDate(li.Find("span.popup_date").First())
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseShoutNotification: someone left a shout on your profile.
|
||||||
|
func parseShoutNotification(li *goquery.Selection) *ShoutNotification {
|
||||||
|
s := &ShoutNotification{Author: userRefFromUsernameBlock(li)}
|
||||||
|
if s.Author.Name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Shout id is sometimes carried on a checkbox value or anchor; not load-
|
||||||
|
// bearing if absent.
|
||||||
|
if v := trimAttr(li.Find("input[type=checkbox]").First(), "value"); v != "" {
|
||||||
|
if n, err := parseID[CommentID](v); err == nil {
|
||||||
|
s.ShoutID = int64(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.PostedAt = parsePopupDate(li.Find("span.popup_date").First())
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// userRefFromUsernameBlock extracts a UserRef from any descendant
|
||||||
|
// c-usernameBlock / c-usernameBlockSimple structure. Returns the zero
|
||||||
|
// UserRef when no such block is found, so callers can keep parsing the row.
|
||||||
|
func userRefFromUsernameBlock(sel *goquery.Selection) UserRef {
|
||||||
|
display := sel.Find(".c-usernameBlockSimple__displayName, .c-usernameBlock__displayName .js-displayName").First()
|
||||||
|
if display.Length() == 0 {
|
||||||
|
display = sel.Find(".c-usernameBlock__displayName").First()
|
||||||
|
}
|
||||||
|
if display.Length() == 0 {
|
||||||
|
return UserRef{}
|
||||||
|
}
|
||||||
|
u := UserRef{DisplayName: trimText(display)}
|
||||||
|
// URL-safe name lives in the surrounding link's href, or in title attr.
|
||||||
|
if t := strings.TrimSpace(trimAttr(display, "title")); t != "" {
|
||||||
|
u.Name = strings.ToLower(t)
|
||||||
|
}
|
||||||
|
if u.Name == "" {
|
||||||
|
link := display.ParentsFiltered("a[href^='/user/']").First()
|
||||||
|
if link.Length() == 0 {
|
||||||
|
link = sel.Find("a[href^='/user/']").First()
|
||||||
|
}
|
||||||
|
href, _ := link.Attr("href")
|
||||||
|
if parts := strings.Split(strings.Trim(href, "/"), "/"); len(parts) >= 2 {
|
||||||
|
u.Name = strings.ToLower(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// ratingFromClass maps a `c-contentRating--general/mature/adult` class
|
||||||
|
// token to one of the Rating constants. Returns "" when unrecognised so
|
||||||
|
// callers can leave the field empty rather than guess.
|
||||||
|
func ratingFromClass(class string) Rating {
|
||||||
|
low := strings.ToLower(class)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(low, "general"):
|
||||||
|
return RatingGeneral
|
||||||
|
case strings.Contains(low, "mature"):
|
||||||
|
return RatingMature
|
||||||
|
case strings.Contains(low, "adult"):
|
||||||
|
return RatingAdult
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
196
notifications_test.go
Normal file
196
notifications_test.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// journalNotifRow renders one /msg/others/ journal-notification <li>. FA's
|
||||||
|
// real markup carries no avatar image on these rows that is the whole
|
||||||
|
// point of issue #14 so this helper deliberately omits one.
|
||||||
|
func journalNotifRow(journalID int, title, authorName, authorDisplay string) string {
|
||||||
|
return fmt.Sprintf(`<li>
|
||||||
|
<div class="table">
|
||||||
|
<div class="cell"><input type="checkbox" name="journals[]" value="%d"></div>
|
||||||
|
<div class="cell">
|
||||||
|
<a href="/journal/%d/"><em class="journal_subject">%s</em></a>
|
||||||
|
(<span class="c-contentRating--general">G</span>)
|
||||||
|
posted by
|
||||||
|
<span class="c-usernameBlockSimple"><a href="/user/%s/"><span class="c-usernameBlockSimple__displayName" title=" %s ">%s</span></a></span>
|
||||||
|
<span class="popup_date" data-time="1779179727">recently</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>`, journalID, journalID, title, authorName, authorName, authorDisplay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeMsgOthersPage wraps journal rows in the section markup parseNotifications
|
||||||
|
// expects.
|
||||||
|
func fakeMsgOthersPage(rows ...string) string {
|
||||||
|
return `<html><head><title>Notifications</title></head><body>
|
||||||
|
<section class="section_container" id="messages-journals">
|
||||||
|
<ul class="message-stream">` + strings.Join(rows, "\n") + `</ul>
|
||||||
|
</section>
|
||||||
|
</body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeUserAvatarPage renders the minimal /user/{name}/ markup fetchUserAvatar
|
||||||
|
// reads just the <userpage-nav-avatar> header element.
|
||||||
|
func fakeUserAvatarPage(name, avatarTS string) string {
|
||||||
|
return `<html><head><title>` + name + `</title></head><body>
|
||||||
|
<userpage-nav-avatar>
|
||||||
|
<a href="/user/` + name + `/"><img alt="` + name + `" src="//a.furaffinity.net/` + avatarTS + `/` + name + `.gif"/></a>
|
||||||
|
</userpage-nav-avatar>
|
||||||
|
</body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifications_ResolvesAvatars(t *testing.T) {
|
||||||
|
var userHits sync.Map // name -> *atomic.Int32
|
||||||
|
hit := func(name string) int32 {
|
||||||
|
v, _ := userHits.LoadOrStore(name, &atomic.Int32{})
|
||||||
|
return v.(*atomic.Int32).Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/msg/others/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(fakeMsgOthersPage(
|
||||||
|
journalNotifRow(101, "First", "authora", "AuthorA"),
|
||||||
|
journalNotifRow(102, "Second", "authora", "AuthorA"), // same author must dedup
|
||||||
|
journalNotifRow(103, "Third", "authorb", "AuthorB"),
|
||||||
|
)))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/user/authora/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hit("authora")
|
||||||
|
_, _ = w.Write([]byte(fakeUserAvatarPage("authora", "100")))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/user/authorb/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hit("authorb")
|
||||||
|
_, _ = w.Write([]byte(fakeUserAvatarPage("authorb", "200")))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
n, err := client.Notifications(context.Background(), WithResolvedAvatars(0))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Notifications: %v", err)
|
||||||
|
}
|
||||||
|
if len(n.Journals) != 3 {
|
||||||
|
t.Fatalf("Journals = %d; want 3", len(n.Journals))
|
||||||
|
}
|
||||||
|
|
||||||
|
want := map[string]string{
|
||||||
|
"authora": "https://a.furaffinity.net/100/authora.gif",
|
||||||
|
"authorb": "https://a.furaffinity.net/200/authorb.gif",
|
||||||
|
}
|
||||||
|
for i, j := range n.Journals {
|
||||||
|
if got := j.Author.AvatarURL; got != want[j.Author.Name] {
|
||||||
|
t.Errorf("Journals[%d] (%s): AvatarURL = %q; want %q",
|
||||||
|
i, j.Author.Name, got, want[j.Author.Name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// authora appears on two journals but must be fetched exactly once.
|
||||||
|
if v, ok := userHits.Load("authora"); !ok || v.(*atomic.Int32).Load() != 1 {
|
||||||
|
got := int32(0)
|
||||||
|
if ok {
|
||||||
|
got = v.(*atomic.Int32).Load()
|
||||||
|
}
|
||||||
|
t.Errorf("/user/authora/ fetched %d times; want 1 (dedup)", got)
|
||||||
|
}
|
||||||
|
if v, ok := userHits.Load("authorb"); !ok || v.(*atomic.Int32).Load() != 1 {
|
||||||
|
t.Error("/user/authorb/ not fetched exactly once")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifications_ResolvedAvatarsRespectsLimit(t *testing.T) {
|
||||||
|
var userPageHits atomic.Int32
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/msg/others/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Four journals, three distinct authors. authora appears twice.
|
||||||
|
_, _ = w.Write([]byte(fakeMsgOthersPage(
|
||||||
|
journalNotifRow(101, "First", "authora", "AuthorA"),
|
||||||
|
journalNotifRow(102, "Second", "authora", "AuthorA"),
|
||||||
|
journalNotifRow(103, "Third", "authorb", "AuthorB"),
|
||||||
|
journalNotifRow(104, "Fourth", "authorc", "AuthorC"),
|
||||||
|
)))
|
||||||
|
})
|
||||||
|
for _, name := range []string{"authora", "authorb", "authorc"} {
|
||||||
|
name := name
|
||||||
|
mux.HandleFunc("/user/"+name+"/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userPageHits.Add(1)
|
||||||
|
_, _ = w.Write([]byte(fakeUserAvatarPage(name, "100")))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
// Budget of 2 distinct authors. Journals resolve in document order, so
|
||||||
|
// authora + authorb get fetched; authorc is past the budget.
|
||||||
|
n, err := client.Notifications(context.Background(), WithResolvedAvatars(2))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Notifications: %v", err)
|
||||||
|
}
|
||||||
|
if len(n.Journals) != 4 {
|
||||||
|
t.Fatalf("Journals = %d; want 4", len(n.Journals))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exactly 2 profile fetches the limit caps *fetches*, not applications.
|
||||||
|
if got := userPageHits.Load(); got != 2 {
|
||||||
|
t.Errorf("/user/ fetched %d times; want 2 (limit)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
byName := map[string]string{}
|
||||||
|
for _, j := range n.Journals {
|
||||||
|
// Both authora rows must agree (cache hit applies past the limit).
|
||||||
|
if prev, seen := byName[j.Author.Name]; seen && prev != j.Author.AvatarURL {
|
||||||
|
t.Errorf("author %s: inconsistent AvatarURL %q vs %q", j.Author.Name, prev, j.Author.AvatarURL)
|
||||||
|
}
|
||||||
|
byName[j.Author.Name] = j.Author.AvatarURL
|
||||||
|
}
|
||||||
|
if byName["authora"] == "" || byName["authorb"] == "" {
|
||||||
|
t.Errorf("authora/authorb should be resolved within budget; got %q / %q",
|
||||||
|
byName["authora"], byName["authorb"])
|
||||||
|
}
|
||||||
|
if byName["authorc"] != "" {
|
||||||
|
t.Errorf("authorc is past the budget; AvatarURL = %q, want empty", byName["authorc"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifications_NoAvatarResolutionByDefault(t *testing.T) {
|
||||||
|
var userPageHits atomic.Int32
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/msg/others/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(fakeMsgOthersPage(
|
||||||
|
journalNotifRow(101, "First", "authora", "AuthorA"),
|
||||||
|
)))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/user/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userPageHits.Add(1)
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newE2EClient(t, srv)
|
||||||
|
n, err := client.Notifications(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Notifications: %v", err)
|
||||||
|
}
|
||||||
|
if len(n.Journals) != 1 {
|
||||||
|
t.Fatalf("Journals = %d; want 1", len(n.Journals))
|
||||||
|
}
|
||||||
|
// Without WithResolvedAvatars the SDK must not touch /user/ pages, and
|
||||||
|
// the avatar stays empty (FA does not provide it on /msg/others/).
|
||||||
|
if n.Journals[0].Author.AvatarURL != "" {
|
||||||
|
t.Errorf("AvatarURL = %q; want empty without WithResolvedAvatars", n.Journals[0].Author.AvatarURL)
|
||||||
|
}
|
||||||
|
if userPageHits.Load() != 0 {
|
||||||
|
t.Errorf("/user/ fetched %d times; want 0 (no resolution requested)", userPageHits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
193
options.go
Normal file
193
options.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cookies are FA's session cookies as they appear in a browser's storage.
|
||||||
|
// Both A and B are required for authenticated requests; either may be empty
|
||||||
|
// for unauthenticated browsing of public pages.
|
||||||
|
type Cookies struct {
|
||||||
|
A string
|
||||||
|
B string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CFCookies carries the Cloudflare clearance cookie obtained from a real
|
||||||
|
// browser. Cloudflare binds Clearance to the exact User-Agent string that
|
||||||
|
// produced it, so callers must also pass [WithUserAgent] with that UA.
|
||||||
|
type CFCookies struct {
|
||||||
|
Clearance string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SFWMode controls whether FA serves the "safe for work" filtered view
|
||||||
|
// that hides mature and adult submission thumbnails. The site exposes this
|
||||||
|
// as a slider in the navbar; under the hood it writes a single `sfw`
|
||||||
|
// cookie that the page-render code reads server-side.
|
||||||
|
//
|
||||||
|
// Note: SFW mode does not change which submissions appear in listings —
|
||||||
|
// adult items still show up, but their thumbnails are replaced with a
|
||||||
|
// "blocked content" placeholder and the file URL is hidden. To filter
|
||||||
|
// listings by rating, use the browse/search rating filters (M4).
|
||||||
|
type SFWMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SFWAuto leaves the cookie alone. FA falls back to the account's
|
||||||
|
// saved preference (or, for anonymous clients, defaults to NSFW
|
||||||
|
// visible). Use this when you don't want the SDK to override what the
|
||||||
|
// user clicked in the browser.
|
||||||
|
SFWAuto SFWMode = iota
|
||||||
|
|
||||||
|
// SFWOn sets `sfw=1` mature and adult content is blocked from
|
||||||
|
// rendering on all pages this client fetches.
|
||||||
|
SFWOn
|
||||||
|
|
||||||
|
// SFWOff sets `sfw=0` mature and adult content renders fully. This
|
||||||
|
// is FA's default for logged-in adult-verified accounts.
|
||||||
|
SFWOff
|
||||||
|
)
|
||||||
|
|
||||||
|
// Option configures a [Client] at construction time.
|
||||||
|
type Option func(*config)
|
||||||
|
|
||||||
|
// config is the internal, fully-resolved client configuration. Defaults are
|
||||||
|
// applied in New before options run.
|
||||||
|
type config struct {
|
||||||
|
cookies Cookies
|
||||||
|
cf CFCookies
|
||||||
|
sfw SFWMode
|
||||||
|
userAgent string
|
||||||
|
rateInterval time.Duration
|
||||||
|
rateBurst int
|
||||||
|
logger *slog.Logger
|
||||||
|
httpClient *http.Client
|
||||||
|
maxRetries int
|
||||||
|
jsonListings bool
|
||||||
|
priorityRL bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultUserAgent identifies this SDK. Callers should override it with
|
||||||
|
// WithUserAgent most importantly so it matches the UA that produced their
|
||||||
|
// cf_clearance cookie.
|
||||||
|
const defaultUserAgent = "go-fa-api/0.1 (+https://git.anthrove.art/public/go-fa-api)"
|
||||||
|
|
||||||
|
// WithCookies sets the FA session cookies (a, b). Without these, requests to
|
||||||
|
// authenticated endpoints will surface as [ErrUnauthorized].
|
||||||
|
func WithCookies(c Cookies) Option {
|
||||||
|
return func(cfg *config) { cfg.cookies = c }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCloudflare sets the cf_clearance cookie used to satisfy Cloudflare's
|
||||||
|
// challenges. Must be paired with [WithUserAgent] using the same UA the
|
||||||
|
// cookie was issued under.
|
||||||
|
func WithCloudflare(c CFCookies) Option {
|
||||||
|
return func(cfg *config) { cfg.cf = c }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSFW overrides the account's saved SFW preference for this Client.
|
||||||
|
// Pass [SFWOn] to force the SFW filter on, [SFWOff] to force it off, or
|
||||||
|
// [SFWAuto] (the default) to leave it unset so the account or anonymous
|
||||||
|
// default takes effect.
|
||||||
|
func WithSFW(mode SFWMode) Option {
|
||||||
|
return func(cfg *config) { cfg.sfw = mode }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithUserAgent overrides the default User-Agent header. When using a
|
||||||
|
// cf_clearance cookie, this must match the browser UA the cookie came from.
|
||||||
|
func WithUserAgent(ua string) Option {
|
||||||
|
return func(cfg *config) { cfg.userAgent = ua }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRateLimit sets the request interval and burst size for the token bucket
|
||||||
|
// that guards every HTTP request the SDK makes. The default (1s, burst 1) is
|
||||||
|
// the safest setting for FurAffinity; tightening it risks Cloudflare bans.
|
||||||
|
func WithRateLimit(interval time.Duration, burst int) Option {
|
||||||
|
return func(cfg *config) {
|
||||||
|
cfg.rateInterval = interval
|
||||||
|
cfg.rateBurst = burst
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRequestsPerSecond is a shorthand for [WithRateLimit] when you think in
|
||||||
|
// terms of throughput. A value of 0.5 means one request every two seconds.
|
||||||
|
func WithRequestsPerSecond(rps float64) Option {
|
||||||
|
return func(cfg *config) {
|
||||||
|
if rps <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.rateInterval = time.Duration(float64(time.Second) / rps)
|
||||||
|
cfg.rateBurst = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithLogger attaches a structured logger. The SDK emits debug records for
|
||||||
|
// retries and rate-limit waits; nothing is logged at info level.
|
||||||
|
func WithLogger(l *slog.Logger) Option {
|
||||||
|
return func(cfg *config) {
|
||||||
|
if l != nil {
|
||||||
|
cfg.logger = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithHTTPClient lets callers supply a fully constructed *http.Client. Useful
|
||||||
|
// for tests (httptest.Server) and for plugging in custom transports such as
|
||||||
|
// uTLS. The SDK still wraps the client's Transport with its rate-limited
|
||||||
|
// transport supply http.DefaultTransport for the default behaviour.
|
||||||
|
func WithHTTPClient(hc *http.Client) Option {
|
||||||
|
return func(cfg *config) { cfg.httpClient = hc }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMaxRetries caps the number of automatic retry attempts on 429/5xx.
|
||||||
|
// Defaults to 3. Set to 0 to disable retries entirely.
|
||||||
|
func WithMaxRetries(n int) Option {
|
||||||
|
return func(cfg *config) {
|
||||||
|
if n >= 0 {
|
||||||
|
cfg.maxRetries = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithExperimentalJSONListings opts into the JSON-first merge strategy for
|
||||||
|
// listing-page parsers (Gallery / Scraps / Favorites / Browse / Search /
|
||||||
|
// SubmissionInbox).
|
||||||
|
//
|
||||||
|
// When enabled, the parser first reads the <script id="js-submissionData">
|
||||||
|
// blob FA embeds on every listing page and uses it as the primary source
|
||||||
|
// for title, author display name, URL-safe login, and avatar URL. HTML
|
||||||
|
// scraping fills in fields the JSON doesn't carry (ID, rating, thumbnail).
|
||||||
|
// If the script is absent or malformed on a given page, the parser
|
||||||
|
// transparently falls back to pure HTML scraping caller sees no error.
|
||||||
|
//
|
||||||
|
// This is gated as "experimental" because: (1) the JSON's description
|
||||||
|
// field carries BBCode rather than HTML and is occasionally truncated, so
|
||||||
|
// we intentionally don't use it; (2) FA could remove the script tag at
|
||||||
|
// any time, in which case the fallback path is what actually runs.
|
||||||
|
//
|
||||||
|
// Defaults to false. Most callers should leave it off until selector
|
||||||
|
// drift becomes a real problem.
|
||||||
|
func WithExperimentalJSONListings(enabled bool) Option {
|
||||||
|
return func(cfg *config) { cfg.jsonListings = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrioritizedRateLimiting enables multi-level priority on the client's
|
||||||
|
// rate limiter. It is an opt-in feature; by default the limiter serves every
|
||||||
|
// request in plain FIFO order.
|
||||||
|
//
|
||||||
|
// When enabled, each request is scheduled according to the [Priority] marker
|
||||||
|
// on its context (see [WithPriority] and [WithBackgroundPriority]): the
|
||||||
|
// limiter serves a higher-priority request before a lower-priority one. A
|
||||||
|
// request with no marker is [PriorityNormal]. The overall pace is unchanged —
|
||||||
|
// there is still a single global token bucket, since FA rate-limits per
|
||||||
|
// account only the order in which competing requests are served.
|
||||||
|
//
|
||||||
|
// Enable this when the application mixes user-driven requests with
|
||||||
|
// best-effort background work (preloading, crawling) and wants the former to
|
||||||
|
// never wait behind the latter. With the feature disabled, [Priority] markers
|
||||||
|
// are inert.
|
||||||
|
//
|
||||||
|
// Defaults to false.
|
||||||
|
func WithPrioritizedRateLimiting(enabled bool) Option {
|
||||||
|
return func(cfg *config) { cfg.priorityRL = enabled }
|
||||||
|
}
|
||||||
58
pagination.go
Normal file
58
pagination.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListOptions configures the pagination of a simple iterator method like
|
||||||
|
// [Client.Gallery] or [Client.Notes]. Filtered iterators ([Client.Search],
|
||||||
|
// [Client.Browse]) use their own option structs that fold the same fields
|
||||||
|
// in alongside their filter parameters.
|
||||||
|
//
|
||||||
|
// Zero values mean "use the SDK defaults": start at page 1, no upper bound
|
||||||
|
// on pages. Pass [ListOptions{MaxPages: 3}] to bound a crawl.
|
||||||
|
type ListOptions struct {
|
||||||
|
// StartPage is the 1-based page to begin iteration on. Zero or 1 = first
|
||||||
|
// page. Useful for resuming after a known-good page.
|
||||||
|
StartPage int
|
||||||
|
|
||||||
|
// MaxPages bounds the number of pages the iterator will request before
|
||||||
|
// stopping. Zero (the default) = unbounded; iteration stops when FA
|
||||||
|
// serves an empty page or omits the "next" link.
|
||||||
|
MaxPages int
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstPage returns the effective starting page (≥ 1).
|
||||||
|
func (o ListOptions) firstPage() int {
|
||||||
|
if o.StartPage < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return o.StartPage
|
||||||
|
}
|
||||||
|
|
||||||
|
// reachedLimit reports whether the iterator has fetched MaxPages pages and
|
||||||
|
// should stop. Always false when MaxPages is 0 (unbounded).
|
||||||
|
func (o ListOptions) reachedLimit(pagesFetched int) bool {
|
||||||
|
return o.MaxPages > 0 && pagesFetched >= o.MaxPages
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectNextPage returns true if doc shows there is a next page available.
|
||||||
|
// FA's beta theme renders pagination as either a Next form button or a
|
||||||
|
// hyperlink with a recognisable label.
|
||||||
|
func detectNextPage(doc *goquery.Document) bool {
|
||||||
|
if doc.Find("form button.button.standard:contains('Next')").Length() > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
hit := false
|
||||||
|
doc.Find("a.button.standard, a.button-link, a.pagination-next").EachWithBreak(func(_ int, sel *goquery.Selection) bool {
|
||||||
|
text := strings.ToLower(trimText(sel))
|
||||||
|
if strings.Contains(text, "next") || strings.Contains(text, "older") {
|
||||||
|
hit = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return hit
|
||||||
|
}
|
||||||
117
parse_helpers.go
Normal file
117
parse_helpers.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parsePopupDate reads a FurAffinity popup_date element. Every popup_date on
|
||||||
|
// the site carries an authoritative `data-time` attribute holding the post
|
||||||
|
// time as Unix seconds; that is preferred. The visible title attribute is a
|
||||||
|
// fallback for older rendered pages that omit data-time.
|
||||||
|
//
|
||||||
|
// Returns the zero time if neither source is parseable. Callers check IsZero
|
||||||
|
// rather than receiving an error, because dates are nice-to-have and
|
||||||
|
// shouldn't fail a whole parse on their own.
|
||||||
|
func parsePopupDate(s *goquery.Selection) time.Time {
|
||||||
|
if s == nil || s.Length() == 0 {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
if v := trimAttr(s, "data-time"); v != "" {
|
||||||
|
if secs, err := strconv.ParseInt(v, 10, 64); err == nil && secs > 0 {
|
||||||
|
return time.Unix(secs, 0).UTC()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw := firstNonEmpty(trimAttr(s, "title"), trimText(s))
|
||||||
|
if t, err := ParseFADate(raw); err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractIntFromHref pulls the first /<numeric>/ segment from an href.
|
||||||
|
// Returns 0 if none is found. Used to lift submission/journal/comment IDs
|
||||||
|
// out of links like "/view/12345678/" or "/journal/4567890/".
|
||||||
|
func extractIntFromHref(href string) int64 {
|
||||||
|
for _, seg := range strings.Split(href, "/") {
|
||||||
|
if seg == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n, err := strconv.ParseInt(seg, 10, 64); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimText is the goquery-friendly equivalent of strings.TrimSpace applied
|
||||||
|
// to a selection's text. Returns "" when the selection is empty.
|
||||||
|
func trimText(s *goquery.Selection) string {
|
||||||
|
if s == nil || s.Length() == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(s.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimAttr returns the trimmed value of attr, or "" if missing.
|
||||||
|
func trimAttr(s *goquery.Selection, attr string) string {
|
||||||
|
if s == nil || s.Length() == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
v, ok := s.Attr(attr)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstNonEmpty returns the first non-empty (after trim) string.
|
||||||
|
func firstNonEmpty(vals ...string) string {
|
||||||
|
for _, v := range vals {
|
||||||
|
if t := strings.TrimSpace(v); t != "" {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// htmlOf returns the inner HTML of a selection, with leading/trailing
|
||||||
|
// whitespace trimmed. Returns "" when the selection is empty or rendering fails.
|
||||||
|
func htmlOf(s *goquery.Selection) string {
|
||||||
|
if s == nil || s.Length() == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
h, err := s.Html()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStatNumber parses a number that may carry commas or surrounding text
|
||||||
|
// (e.g. "1,234 views"). Returns 0 on any failure rather than propagating an
|
||||||
|
// error stats are nice-to-have, not load-bearing.
|
||||||
|
func parseStatNumber(s string) int {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// Strip everything that isn't a digit or comma; keep digits & commas only.
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if r >= '0' && r <= '9' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b.Len() == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(b.String())
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
295
ratelimit.go
Normal file
295
ratelimit.go
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Priority orders requests competing for the shared rate-limiter token. A
|
||||||
|
// lower numeric value is a higher priority: the limiter always serves a
|
||||||
|
// waiting request of higher priority before one of lower priority.
|
||||||
|
//
|
||||||
|
// Priority only decides the order in which competing requests reserve the
|
||||||
|
// next token never how fast tokens are emitted. There is still a single
|
||||||
|
// global token bucket, because FurAffinity rate-limits per account.
|
||||||
|
//
|
||||||
|
// Priority markers take effect only on a Client built with
|
||||||
|
// [WithPrioritizedRateLimiting]. Without it every request is served in plain
|
||||||
|
// FIFO order and the marker is inert so setting a priority is always safe.
|
||||||
|
type Priority int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// PriorityInteractive is the highest priority: requests the user is
|
||||||
|
// actively waiting on the page on screen, user write actions.
|
||||||
|
PriorityInteractive Priority = iota
|
||||||
|
|
||||||
|
// PriorityNormal is the default priority of any request whose context
|
||||||
|
// carries no priority marker.
|
||||||
|
PriorityNormal
|
||||||
|
|
||||||
|
// PriorityLow is below normal: best-effort work that should yield to
|
||||||
|
// user-driven traffic, such as speculative preloading of likely-next
|
||||||
|
// submissions.
|
||||||
|
PriorityLow
|
||||||
|
|
||||||
|
// PriorityBackground is the lowest priority: bulk background work such
|
||||||
|
// as inbox or watchlist crawling. It yields to every other level.
|
||||||
|
PriorityBackground
|
||||||
|
)
|
||||||
|
|
||||||
|
// numPriorities is the count of defined Priority levels.
|
||||||
|
const numPriorities = 4
|
||||||
|
|
||||||
|
// String returns the constant name of p, for log readability.
|
||||||
|
func (p Priority) String() string {
|
||||||
|
switch p {
|
||||||
|
case PriorityInteractive:
|
||||||
|
return "interactive"
|
||||||
|
case PriorityNormal:
|
||||||
|
return "normal"
|
||||||
|
case PriorityLow:
|
||||||
|
return "low"
|
||||||
|
case PriorityBackground:
|
||||||
|
return "background"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// priorityKey is the context key carrying a request's [Priority].
|
||||||
|
type priorityKey struct{}
|
||||||
|
|
||||||
|
// WithPriority marks ctx and every SDK call made with it at priority p.
|
||||||
|
// A context that carries no marker resolves to [PriorityNormal].
|
||||||
|
//
|
||||||
|
// A p outside [PriorityInteractive, PriorityBackground] is clamped into that
|
||||||
|
// range; callers using the named constants never trigger this.
|
||||||
|
//
|
||||||
|
// This marker takes effect only on a Client built with
|
||||||
|
// [WithPrioritizedRateLimiting]; otherwise it is inert. See [Priority].
|
||||||
|
func WithPriority(ctx context.Context, p Priority) context.Context {
|
||||||
|
if p < PriorityInteractive {
|
||||||
|
p = PriorityInteractive
|
||||||
|
}
|
||||||
|
if p > PriorityBackground {
|
||||||
|
p = PriorityBackground
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, priorityKey{}, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithBackgroundPriority marks ctx and every SDK call made with it as
|
||||||
|
// lowest-priority background work. It is exactly
|
||||||
|
// WithPriority(ctx, [PriorityBackground]).
|
||||||
|
//
|
||||||
|
// A background request yields the rate limiter to every higher-priority
|
||||||
|
// request: it will not take the next token while any higher-priority request
|
||||||
|
// is queued for one. This does not change the overall pace there is still a
|
||||||
|
// single global token bucket, because FA rate-limits per account only the
|
||||||
|
// order in which competing requests are served.
|
||||||
|
//
|
||||||
|
// Use it for best-effort work (preloading, crawling) so it never delays a
|
||||||
|
// request the user is actively waiting on. Background requests can be starved
|
||||||
|
// indefinitely by a steady stream of higher-priority traffic; that is
|
||||||
|
// intentional for best-effort work.
|
||||||
|
//
|
||||||
|
// This marker only takes effect on a Client built with
|
||||||
|
// [WithPrioritizedRateLimiting]. On a client without it the marker is inert
|
||||||
|
// and every request is served in plain FIFO order.
|
||||||
|
func WithBackgroundPriority(ctx context.Context) context.Context {
|
||||||
|
return WithPriority(ctx, PriorityBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
// priorityOf reports the [Priority] ctx was decorated with, or
|
||||||
|
// [PriorityNormal] when ctx is nil or carries no marker.
|
||||||
|
func priorityOf(ctx context.Context) Priority {
|
||||||
|
if ctx == nil {
|
||||||
|
return PriorityNormal
|
||||||
|
}
|
||||||
|
p, ok := ctx.Value(priorityKey{}).(Priority)
|
||||||
|
if !ok {
|
||||||
|
return PriorityNormal
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// rateLimiter paces the HTTP requests the SDK makes.
|
||||||
|
//
|
||||||
|
// With priority scheduling disabled (the default) it is a plain FIFO token
|
||||||
|
// bucket: [rate.Limiter] grants reservations in call order.
|
||||||
|
//
|
||||||
|
// With priority scheduling enabled it is instead a purpose-built
|
||||||
|
// priority-aware token bucket. Tokens accrue at the same fixed rate, but a
|
||||||
|
// waiting request consumes the next token only once no higher-priority
|
||||||
|
// request is also waiting so the highest-priority waiter is always served
|
||||||
|
// first. The emission rate is unchanged; priority only reorders who consumes
|
||||||
|
// each token. Requests at the same priority level race for each token.
|
||||||
|
type rateLimiter struct {
|
||||||
|
lim *rate.Limiter // FIFO pacing; used when priority is disabled
|
||||||
|
priority bool // honour Priority markers via the bucket below
|
||||||
|
|
||||||
|
// Priority-aware token bucket, used only when priority is enabled.
|
||||||
|
mu sync.Mutex
|
||||||
|
interval time.Duration // time to accrue one token
|
||||||
|
burst float64 // token-bucket capacity
|
||||||
|
tokens float64 // tokens currently available
|
||||||
|
last time.Time // when tokens was last accrued
|
||||||
|
waiters [numPriorities]int // requests currently waiting at each level
|
||||||
|
changed chan struct{} // closed and replaced on every waiter-set change
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRateLimiter constructs a token-bucket limiter that emits one token per
|
||||||
|
// interval, with the given burst size. A burst of 1 yields strict pacing;
|
||||||
|
// burst > 1 lets short bursts through before the steady rate kicks in.
|
||||||
|
//
|
||||||
|
// priority enables priority-aware scheduling; when false the limiter is a
|
||||||
|
// plain FIFO bucket and [Priority] markers are ignored.
|
||||||
|
func newRateLimiter(interval time.Duration, burst int, priority bool) *rateLimiter {
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = time.Second
|
||||||
|
}
|
||||||
|
if burst <= 0 {
|
||||||
|
burst = 1
|
||||||
|
}
|
||||||
|
return &rateLimiter{
|
||||||
|
lim: rate.NewLimiter(rate.Every(interval), burst),
|
||||||
|
priority: priority,
|
||||||
|
interval: interval,
|
||||||
|
burst: float64(burst),
|
||||||
|
tokens: float64(burst),
|
||||||
|
last: time.Now(),
|
||||||
|
changed: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait blocks until a token is available or ctx is cancelled.
|
||||||
|
//
|
||||||
|
// With priority scheduling disabled it is a plain FIFO token reservation.
|
||||||
|
// With it enabled the request waits until no higher-priority request is
|
||||||
|
// queued and a token is available, then consumes it. Cancellation propagates
|
||||||
|
// through both the limiter and the surrounding HTTP request.
|
||||||
|
//
|
||||||
|
// Requests at the same priority level are not ordered relative to one
|
||||||
|
// another they race for each token. Lower-priority levels can be starved
|
||||||
|
// indefinitely by a steady stream of higher-priority traffic; that is
|
||||||
|
// intentional for best-effort background work.
|
||||||
|
func (r *rateLimiter) wait(ctx context.Context) error {
|
||||||
|
if r == nil || r.lim == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Priority scheduling is opt-in (see WithPrioritizedRateLimiting). When
|
||||||
|
// off, every request is a plain FIFO reservation and any Priority marker
|
||||||
|
// on ctx is ignored.
|
||||||
|
if !r.priority {
|
||||||
|
return r.lim.Wait(ctx)
|
||||||
|
}
|
||||||
|
return r.waitPriority(ctx, priorityOf(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitPriority serves a request at priority p from the priority-aware token
|
||||||
|
// bucket. It registers the request so lower-priority requests defer to it,
|
||||||
|
// then loops until it can consume a token: it sleeps until the bucket should
|
||||||
|
// have refilled, waking early whenever the waiter set changes (a request
|
||||||
|
// arrived or left) to re-evaluate its turn.
|
||||||
|
func (r *rateLimiter) waitPriority(ctx context.Context, p Priority) error {
|
||||||
|
r.register(p)
|
||||||
|
defer r.unregister(p)
|
||||||
|
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
r.refill()
|
||||||
|
// The eligibility check and the consume happen in one critical
|
||||||
|
// section, so a higher-priority register cannot interleave between
|
||||||
|
// them.
|
||||||
|
blocked := r.blockingAbove(p)
|
||||||
|
if !blocked && r.tokens >= 1 {
|
||||||
|
r.tokens--
|
||||||
|
r.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ch := r.changed
|
||||||
|
var sleep time.Duration
|
||||||
|
if !blocked {
|
||||||
|
// Highest-priority waiter, but short of a token: sleep until the
|
||||||
|
// bucket should have accrued one.
|
||||||
|
sleep = time.Duration((1 - r.tokens) * float64(r.interval))
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
// sleep <= 0 means this request is blocked behind a higher-priority
|
||||||
|
// level: wait only for the waiter set to change (or for cancellation).
|
||||||
|
if sleep <= 0 {
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(sleep)
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
case <-ch:
|
||||||
|
timer.Stop()
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// refill accrues tokens for the time elapsed since the last refill, capped at
|
||||||
|
// the bucket's burst capacity. Caller must hold r.mu.
|
||||||
|
func (r *rateLimiter) refill() {
|
||||||
|
now := time.Now()
|
||||||
|
r.tokens += float64(now.Sub(r.last)) / float64(r.interval)
|
||||||
|
if r.tokens > r.burst {
|
||||||
|
r.tokens = r.burst
|
||||||
|
}
|
||||||
|
r.last = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockingAbove reports whether any request strictly higher-priority than p
|
||||||
|
// is currently waiting. Caller must hold r.mu.
|
||||||
|
func (r *rateLimiter) blockingAbove(p Priority) bool {
|
||||||
|
for q := PriorityInteractive; q < p; q++ {
|
||||||
|
if r.waiters[q] > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// register records a request now waiting at priority p and wakes every other
|
||||||
|
// waiter so it can re-evaluate its turn.
|
||||||
|
func (r *rateLimiter) register(p Priority) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.waiters[p]++
|
||||||
|
r.broadcast()
|
||||||
|
}
|
||||||
|
|
||||||
|
// unregister records that a request waiting at priority p has finished (it
|
||||||
|
// consumed a token, or its context was cancelled) and wakes every other
|
||||||
|
// waiter so it can re-evaluate its turn.
|
||||||
|
func (r *rateLimiter) unregister(p Priority) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.waiters[p]--
|
||||||
|
r.broadcast()
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcast wakes every goroutine selecting on the current changed channel
|
||||||
|
// and installs a fresh one for the next wait. It intentionally over-wakes —
|
||||||
|
// every waiter re-evaluates its turn even if its eligibility did not change.
|
||||||
|
// Caller must hold r.mu.
|
||||||
|
func (r *rateLimiter) broadcast() {
|
||||||
|
close(r.changed)
|
||||||
|
r.changed = make(chan struct{})
|
||||||
|
}
|
||||||
268
ratelimit_test.go
Normal file
268
ratelimit_test.go
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRateLimiter_ShapesThroughput asserts that the rate limiter actually
|
||||||
|
// paces requests. Uses real time (small intervals) rather than synctest so
|
||||||
|
// it works on any Go 1.25+ host without GOEXPERIMENT.
|
||||||
|
func TestRateLimiter_ShapesThroughput(t *testing.T) {
|
||||||
|
lim := newRateLimiter(50*time.Millisecond, 1, false)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
const N = 4
|
||||||
|
for i := 0; i < N; i++ {
|
||||||
|
if err := lim.wait(ctx); err != nil {
|
||||||
|
t.Fatalf("wait: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
// With burst=1 and interval=50ms, N=4 should take at least 3*50ms = 150ms.
|
||||||
|
// Allow a small lower-bound floor; the first token is free with burst=1.
|
||||||
|
if elapsed < 100*time.Millisecond {
|
||||||
|
t.Fatalf("expected at least 100ms for %d requests; got %v", N, elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimiter_RespectsContextCancel(t *testing.T) {
|
||||||
|
lim := newRateLimiter(time.Hour, 1, false) // effectively never refills
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
// Drain the burst token.
|
||||||
|
if err := lim.wait(ctx); err != nil {
|
||||||
|
t.Fatalf("first wait: %v", err)
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
err := lim.wait(ctx)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error from cancelled context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimiter_NilSafe(t *testing.T) {
|
||||||
|
var r *rateLimiter
|
||||||
|
if err := r.wait(context.Background()); err != nil {
|
||||||
|
t.Fatalf("nil-receiver wait should be no-op; got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithPriority_ContextMarker(t *testing.T) {
|
||||||
|
if got := priorityOf(context.Background()); got != PriorityNormal {
|
||||||
|
t.Errorf("undecorated context = %v; want PriorityNormal", got)
|
||||||
|
}
|
||||||
|
if got := priorityOf(nil); got != PriorityNormal {
|
||||||
|
t.Errorf("nil context = %v; want PriorityNormal", got)
|
||||||
|
}
|
||||||
|
if got := priorityOf(WithBackgroundPriority(context.Background())); got != PriorityBackground {
|
||||||
|
t.Errorf("WithBackgroundPriority = %v; want PriorityBackground", got)
|
||||||
|
}
|
||||||
|
if got := priorityOf(WithPriority(context.Background(), PriorityInteractive)); got != PriorityInteractive {
|
||||||
|
t.Errorf("WithPriority(Interactive) = %v; want PriorityInteractive", got)
|
||||||
|
}
|
||||||
|
// The marker survives further decoration.
|
||||||
|
ctx, cancel := context.WithCancel(WithPriority(context.Background(), PriorityLow))
|
||||||
|
defer cancel()
|
||||||
|
if got := priorityOf(ctx); got != PriorityLow {
|
||||||
|
t.Errorf("priority marker should survive a child context; got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderRecorder collects the completion order of concurrent wait calls.
|
||||||
|
type orderRecorder struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
seen []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *orderRecorder) wait(t *testing.T, wg *sync.WaitGroup, lim *rateLimiter, ctx context.Context, name string) {
|
||||||
|
t.Helper()
|
||||||
|
defer wg.Done()
|
||||||
|
if err := lim.wait(ctx); err != nil {
|
||||||
|
t.Errorf("%s wait: %v", name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
o.mu.Lock()
|
||||||
|
o.seen = append(o.seen, name)
|
||||||
|
o.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *orderRecorder) joined() string {
|
||||||
|
o.mu.Lock()
|
||||||
|
defer o.mu.Unlock()
|
||||||
|
return strings.Join(o.seen, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimiter_BackgroundDefersToForeground: with priority enabled, a
|
||||||
|
// background request (B) defers to BOTH foreground requests even C, which
|
||||||
|
// is issued *after* B so B completes last. A and C are the same priority
|
||||||
|
// and race, so their order relative to each other is unspecified.
|
||||||
|
func TestRateLimiter_BackgroundDefersToForeground(t *testing.T) {
|
||||||
|
lim := newRateLimiter(60*time.Millisecond, 1, true)
|
||||||
|
bg := context.Background()
|
||||||
|
if err := lim.wait(bg); err != nil { // drain the burst token
|
||||||
|
t.Fatalf("drain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var order orderRecorder
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(3)
|
||||||
|
|
||||||
|
go order.wait(t, &wg, lim, bg, "A") // foreground, t0
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
go order.wait(t, &wg, lim, WithBackgroundPriority(bg), "B") // background, t0+20ms
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
go order.wait(t, &wg, lim, bg, "C") // foreground, t0+40ms
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
// A and C are both foreground (PriorityNormal); same-level requests race,
|
||||||
|
// so their relative order is unspecified. The guarantee is only that the
|
||||||
|
// background request B is served last.
|
||||||
|
if got := order.joined(); got != "A,C,B" && got != "C,A,B" {
|
||||||
|
t.Errorf("completion order = %q; want background (B) last, foreground A and C before it in either order", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimiter_PriorityDisabledServesFIFO: the same scenario with priority
|
||||||
|
// disabled the WithBackgroundPriority marker is inert, so requests are
|
||||||
|
// served strictly in the order they queued: A, B, C.
|
||||||
|
func TestRateLimiter_PriorityDisabledServesFIFO(t *testing.T) {
|
||||||
|
lim := newRateLimiter(60*time.Millisecond, 1, false)
|
||||||
|
bg := context.Background()
|
||||||
|
if err := lim.wait(bg); err != nil { // drain the burst token
|
||||||
|
t.Fatalf("drain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var order orderRecorder
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(3)
|
||||||
|
|
||||||
|
go order.wait(t, &wg, lim, bg, "A")
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
go order.wait(t, &wg, lim, WithBackgroundPriority(bg), "B") // marked, but ignored
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
go order.wait(t, &wg, lim, bg, "C")
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
if got := order.joined(); got != "A,B,C" {
|
||||||
|
t.Errorf("completion order = %q; want \"A,B,C\" (priority off marker must be inert)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimiter_BackgroundProceedsWithoutForeground confirms a background
|
||||||
|
// request does not hang when nothing foreground is competing.
|
||||||
|
func TestRateLimiter_BackgroundProceedsWithoutForeground(t *testing.T) {
|
||||||
|
lim := newRateLimiter(20*time.Millisecond, 1, true)
|
||||||
|
ctx := WithBackgroundPriority(context.Background())
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
c, cancel := context.WithTimeout(ctx, time.Second)
|
||||||
|
err := lim.wait(c)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("background wait %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimiter_BackgroundWaitRespectsCancel confirms a background request
|
||||||
|
// blocked behind a foreground request still honours context cancellation.
|
||||||
|
func TestRateLimiter_BackgroundWaitRespectsCancel(t *testing.T) {
|
||||||
|
lim := newRateLimiter(time.Hour, 1, true) // bucket effectively never refills
|
||||||
|
bg := context.Background()
|
||||||
|
if err := lim.wait(bg); err != nil {
|
||||||
|
t.Fatalf("drain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A foreground request parks in wait forever, keeping the gate closed.
|
||||||
|
fgCtx, fgCancel := context.WithCancel(bg)
|
||||||
|
defer fgCancel()
|
||||||
|
fgStarted := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
close(fgStarted)
|
||||||
|
_ = lim.wait(fgCtx)
|
||||||
|
}()
|
||||||
|
<-fgStarted
|
||||||
|
time.Sleep(20 * time.Millisecond) // ensure the foreground counter is set
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(WithBackgroundPriority(bg))
|
||||||
|
go func() {
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
if err := lim.wait(ctx); err == nil {
|
||||||
|
t.Fatal("expected cancellation error for background wait blocked behind foreground")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNew_PrioritizedRateLimitingOption verifies the construction-time
|
||||||
|
// opt-in wires through to the limiter.
|
||||||
|
func TestNew_PrioritizedRateLimitingOption(t *testing.T) {
|
||||||
|
if New().limiter.priority {
|
||||||
|
t.Error("default client: priority scheduling should be off")
|
||||||
|
}
|
||||||
|
if !New(WithPrioritizedRateLimiting(true)).limiter.priority {
|
||||||
|
t.Error("WithPrioritizedRateLimiting(true): priority scheduling should be on")
|
||||||
|
}
|
||||||
|
if New(WithPrioritizedRateLimiting(false)).limiter.priority {
|
||||||
|
t.Error("WithPrioritizedRateLimiting(false): priority scheduling should be off")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPriority_String(t *testing.T) {
|
||||||
|
cases := map[Priority]string{
|
||||||
|
PriorityInteractive: "interactive",
|
||||||
|
PriorityNormal: "normal",
|
||||||
|
PriorityLow: "low",
|
||||||
|
PriorityBackground: "background",
|
||||||
|
Priority(42): "unknown",
|
||||||
|
}
|
||||||
|
for p, want := range cases {
|
||||||
|
if got := p.String(); got != want {
|
||||||
|
t.Errorf("Priority(%d).String() = %q; want %q", int(p), got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithPriority_ClampsOutOfRange(t *testing.T) {
|
||||||
|
if got := priorityOf(WithPriority(context.Background(), Priority(-5))); got != PriorityInteractive {
|
||||||
|
t.Errorf("below-range priority = %v; want PriorityInteractive", got)
|
||||||
|
}
|
||||||
|
if got := priorityOf(WithPriority(context.Background(), Priority(99))); got != PriorityBackground {
|
||||||
|
t.Errorf("above-range priority = %v; want PriorityBackground", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimiter_NLevelPriorityOrder: with priority enabled, four requests
|
||||||
|
// queued lowest-first must complete highest-priority-first, not in arrival
|
||||||
|
// order.
|
||||||
|
func TestRateLimiter_NLevelPriorityOrder(t *testing.T) {
|
||||||
|
lim := newRateLimiter(60*time.Millisecond, 1, true)
|
||||||
|
bg := context.Background()
|
||||||
|
if err := lim.wait(bg); err != nil { // drain the burst token
|
||||||
|
t.Fatalf("drain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var order orderRecorder
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(4)
|
||||||
|
|
||||||
|
go order.wait(t, &wg, lim, WithPriority(bg, PriorityBackground), "background")
|
||||||
|
time.Sleep(15 * time.Millisecond)
|
||||||
|
go order.wait(t, &wg, lim, WithPriority(bg, PriorityLow), "low")
|
||||||
|
time.Sleep(15 * time.Millisecond)
|
||||||
|
go order.wait(t, &wg, lim, WithPriority(bg, PriorityNormal), "normal")
|
||||||
|
time.Sleep(15 * time.Millisecond)
|
||||||
|
go order.wait(t, &wg, lim, WithPriority(bg, PriorityInteractive), "interactive")
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
if got := order.joined(); got != "interactive,normal,low,background" {
|
||||||
|
t.Errorf("completion order = %q; want \"interactive,normal,low,background\"", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
461
real_fixtures_test.go
Normal file
461
real_fixtures_test.go
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file groups parser tests that exercise the *additional* fixtures
|
||||||
|
// captured by the extended TestRefreshFixtures (story / shouts / last page
|
||||||
|
// / scraps / favorites / journals listing / journal comments / system
|
||||||
|
// message). Each test t.Skip's cleanly when its fixture isn't present.
|
||||||
|
|
||||||
|
// TestParseSubmission_StoryRealFixture verifies the non-image submission
|
||||||
|
// path. FA still renders a #submissionImg for stories, but it's a generated
|
||||||
|
// thumbnail (a .gif preview of the document) FileURL must point at the
|
||||||
|
// real document URL from the Download button, not the thumbnail.
|
||||||
|
func TestParseSubmission_StoryRealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "submission_story.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
sub, err := parseSubmission(0, doc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseSubmission(story): %v", err)
|
||||||
|
}
|
||||||
|
if sub.Title == "" {
|
||||||
|
t.Error("story fixture: Title is empty")
|
||||||
|
}
|
||||||
|
if sub.Author.Name == "" {
|
||||||
|
t.Error("story fixture: Author.Name is empty")
|
||||||
|
}
|
||||||
|
if sub.FileURL == "" {
|
||||||
|
t.Fatal("story fixture: FileURL is empty (Download button selector missed?)")
|
||||||
|
}
|
||||||
|
// The thumbnail FA injects into #submissionImg ends in .gif; the real
|
||||||
|
// document does not. Catching this is the whole point of the fixture.
|
||||||
|
if strings.HasSuffix(sub.FileURL, ".gif") {
|
||||||
|
t.Errorf("story FileURL = %q; points at the #submissionImg thumbnail gif, not the document", sub.FileURL)
|
||||||
|
}
|
||||||
|
if !strings.Contains(sub.FileURL, "/download/") {
|
||||||
|
t.Errorf("story FileURL = %q; want the Download-button URL (.../download/...)", sub.FileURL)
|
||||||
|
}
|
||||||
|
if sub.Category != "Story" {
|
||||||
|
t.Errorf("story Category = %q; want %q", sub.Category, "Story")
|
||||||
|
}
|
||||||
|
// The captured page renders a "+Fav" link (viewer has not favorited it),
|
||||||
|
// so Favorited must be false against this real markup.
|
||||||
|
if sub.Favorited {
|
||||||
|
t.Error("story fixture: Favorited = true; fixture page shows the +Fav link")
|
||||||
|
}
|
||||||
|
t.Logf("story struct: %+v", sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseUser_WithShoutsRealFixture exercises the shouts parser. If the
|
||||||
|
// captured profile happens to have zero shouts (e.g. shouts disabled), the
|
||||||
|
// test logs that rather than failing the assertion is that parsing didn't
|
||||||
|
// crash, not that the user had shouts.
|
||||||
|
func TestParseUser_WithShoutsRealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "user_with_shouts.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
u, err := parseUser("fixture", doc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseUser(shouts): %v", err)
|
||||||
|
}
|
||||||
|
if u.DisplayName == "" {
|
||||||
|
t.Error("shouts fixture: DisplayName is empty")
|
||||||
|
}
|
||||||
|
if len(u.Shouts) == 0 {
|
||||||
|
t.Logf("shouts fixture: 0 shouts parsed either the user has none, or the selector missed them")
|
||||||
|
} else {
|
||||||
|
t.Logf("shouts fixture: parsed %d shouts", len(u.Shouts))
|
||||||
|
first := u.Shouts[0]
|
||||||
|
if first.Author.DisplayName == "" && first.BodyHTML == "" {
|
||||||
|
t.Error("shouts fixture: first shout has empty Author + Body")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseGalleryPage_LastPageRealFixture asserts that detectNextPage
|
||||||
|
// returns false on the last gallery page. A trailing page that still
|
||||||
|
// reports "next" usually means our pagination selector matched a button on
|
||||||
|
// the page header instead of the paginator.
|
||||||
|
func TestParseGalleryPage_LastPageRealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "gallery_page_last.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
items, hasNext := parseGalleryPage(doc, false)
|
||||||
|
t.Logf("last page: %d items, hasNext=%v", len(items), hasNext)
|
||||||
|
if hasNext {
|
||||||
|
t.Error("last page fixture: hasNext = true; detectNextPage likely matched a non-paginator button")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseGalleryPage_ScrapsRealFixture confirms the same parser works on
|
||||||
|
// /scraps/ pages. Scraps and gallery share figure[id^=sid-] markup.
|
||||||
|
func TestParseGalleryPage_ScrapsRealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "scraps_page1.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
items, hasNext := parseGalleryPage(doc, false)
|
||||||
|
t.Logf("scraps page1: %d items, hasNext=%v", len(items), hasNext)
|
||||||
|
for i, it := range items {
|
||||||
|
if it.ID == 0 {
|
||||||
|
t.Errorf("scraps item %d: ID == 0", i)
|
||||||
|
}
|
||||||
|
if it.Title == "" {
|
||||||
|
t.Errorf("scraps item %d: empty Title", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseGalleryPage_FavoritesRealFixture verifies that on a favorites
|
||||||
|
// page, the per-item Author reflects the original artist (not the user
|
||||||
|
// whose favorites we are walking). This is the single load-bearing
|
||||||
|
// difference between gallery and favorites parsing.
|
||||||
|
func TestParseGalleryPage_FavoritesRealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "favorites_page1.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
items, hasNext := parseGalleryPage(doc, false)
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Fatal("favorites fixture: no items parsed")
|
||||||
|
}
|
||||||
|
t.Logf("favorites: %d items, hasNext=%v", len(items), hasNext)
|
||||||
|
|
||||||
|
withAuthor := 0
|
||||||
|
for i, it := range items {
|
||||||
|
if it.ID == 0 {
|
||||||
|
t.Errorf("fav item %d: ID == 0", i)
|
||||||
|
}
|
||||||
|
if it.Title == "" {
|
||||||
|
t.Errorf("fav item %d: empty Title", i)
|
||||||
|
}
|
||||||
|
if it.Author.Name != "" {
|
||||||
|
withAuthor++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// We require Author on at least the majority of items; FA occasionally
|
||||||
|
// renders a "blocked" placeholder figure without a usable author link.
|
||||||
|
if withAuthor < len(items)/2 {
|
||||||
|
t.Errorf("favorites fixture: only %d/%d items had Author.Name set figcaption /user/ selector likely off",
|
||||||
|
withAuthor, len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseUserJournalsPage_RealFixture parses the journals listing
|
||||||
|
// captured for FA_TEST_JOURNALS_USER. Zero entries is acceptable (the user
|
||||||
|
// may have no journals); the test asserts the parser doesn't crash and
|
||||||
|
// pagination detection doesn't return a false-positive next link.
|
||||||
|
func TestParseUserJournalsPage_RealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "journals_listing_page1.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
entries, hasNext := parseUserJournalsPage(doc)
|
||||||
|
t.Logf("journals listing: %d entries, hasNext=%v", len(entries), hasNext)
|
||||||
|
if len(entries) == 0 && hasNext {
|
||||||
|
t.Error("journals listing fixture: zero entries but hasNext=true; pagination selector likely matched a header button")
|
||||||
|
}
|
||||||
|
for i, j := range entries {
|
||||||
|
if j.ID == 0 && j.Title == "" {
|
||||||
|
t.Errorf("journals entry %d: both ID and Title empty", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseComments_JournalRealFixture confirms the comment parser works
|
||||||
|
// on /journal/ pages, not just /view/ pages.
|
||||||
|
func TestParseComments_JournalRealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "comments_journal.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
cs := parseComments(doc)
|
||||||
|
t.Logf("journal comments: %d", len(cs))
|
||||||
|
for i, c := range cs {
|
||||||
|
if c.Depth < 0 {
|
||||||
|
t.Errorf("journal comment %d: negative depth %d", i, c.Depth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseSearchResults_RealFixture verifies parseSearchResults against
|
||||||
|
// the captured /search/?q=dragon fixture. Expects ~72 items, hasNext=true
|
||||||
|
// (there are over a million dragon submissions), and per-item Author
|
||||||
|
// populated from the figcaption.
|
||||||
|
func TestParseSearchResults_RealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "search_results.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
items, hasNext := parseSearchResults(doc, false)
|
||||||
|
t.Logf("search: %d items, hasNext=%v", len(items), hasNext)
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Fatal("search fixture: no items parsed")
|
||||||
|
}
|
||||||
|
if len(items) < 50 {
|
||||||
|
t.Errorf("search fixture: only %d items parsed; expected ~72 selector drift?", len(items))
|
||||||
|
}
|
||||||
|
if !hasNext {
|
||||||
|
t.Error("search fixture: hasNext = false; pagination Next anchor not detected")
|
||||||
|
}
|
||||||
|
withAuthor := 0
|
||||||
|
for _, it := range items {
|
||||||
|
if it.Author.Name != "" {
|
||||||
|
withAuthor++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if withAuthor < len(items)/2 {
|
||||||
|
t.Errorf("search: only %d/%d items have Author.Name", withAuthor, len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseNotifications_RealFixture parses the captured /msg/others/ page.
|
||||||
|
// The captured account only has Journal notifications pending, so this
|
||||||
|
// test asserts journals are populated and the other categories at least
|
||||||
|
// don't crash (they come back as nil slices, which is the correct shape).
|
||||||
|
func TestParseNotifications_RealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "msg_others.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
n, err := parseNotifications(doc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseNotifications: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("notifications: journals=%d watches=%d subComments=%d journalComments=%d favs=%d shouts=%d",
|
||||||
|
len(n.Journals), len(n.Watches),
|
||||||
|
len(n.SubmissionComments), len(n.JournalComments),
|
||||||
|
len(n.Favorites), len(n.Shouts))
|
||||||
|
|
||||||
|
if len(n.Journals) == 0 {
|
||||||
|
t.Fatal("expected at least one Journal notification in fixture")
|
||||||
|
}
|
||||||
|
for i, j := range n.Journals {
|
||||||
|
if j.JournalID == 0 {
|
||||||
|
t.Errorf("journal[%d]: JournalID == 0", i)
|
||||||
|
}
|
||||||
|
if j.Title == "" {
|
||||||
|
t.Errorf("journal[%d]: empty Title", i)
|
||||||
|
}
|
||||||
|
if j.Author.Name == "" {
|
||||||
|
t.Errorf("journal[%d]: empty Author.Name", i)
|
||||||
|
}
|
||||||
|
if j.PostedAt.IsZero() {
|
||||||
|
t.Errorf("journal[%d]: zero PostedAt", i)
|
||||||
|
}
|
||||||
|
if j.Rating == "" {
|
||||||
|
t.Errorf("journal[%d]: empty Rating", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseNotesInboxPage_RealFixture parses /msg/pms/. We assert subject,
|
||||||
|
// sender, sent-at, and note id were extracted for each row.
|
||||||
|
func TestParseNotesInboxPage_RealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "msg_pms.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
notes, nextURL := parseNotesInboxPage(doc)
|
||||||
|
t.Logf("notes inbox: %d items, nextURL=%q", len(notes), nextURL)
|
||||||
|
if len(notes) == 0 {
|
||||||
|
t.Fatal("expected at least one note in inbox fixture")
|
||||||
|
}
|
||||||
|
deletedSenders := 0
|
||||||
|
for i, np := range notes {
|
||||||
|
if np.ID == 0 {
|
||||||
|
t.Errorf("note[%d]: ID == 0 (href=%q)", i, np.ThreadURL)
|
||||||
|
}
|
||||||
|
if np.Subject == "" {
|
||||||
|
t.Errorf("note[%d]: empty Subject", i)
|
||||||
|
}
|
||||||
|
// FA renders notes from removed accounts with no usernameBlock and a
|
||||||
|
// [deleted] sentinel; that's expected. Count and don't fail.
|
||||||
|
if np.Sender.Name == "" {
|
||||||
|
if np.Sender.DisplayName == "[deleted]" {
|
||||||
|
deletedSenders++
|
||||||
|
} else {
|
||||||
|
t.Errorf("note[%d]: empty Sender.Name (Display=%q)", i, np.Sender.DisplayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if np.SentAt.IsZero() {
|
||||||
|
t.Errorf("note[%d]: zero SentAt", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if deletedSenders > 0 {
|
||||||
|
t.Logf("notes inbox: %d/%d items had deleted senders", deletedSenders, len(notes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseNote_RealFixture parses /viewmessage/{id}/ and asserts subject,
|
||||||
|
// from, to, and body are populated.
|
||||||
|
func TestParseNote_RealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "note_view.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
n, err := parseNote(0, doc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseNote: %v", err)
|
||||||
|
}
|
||||||
|
if n.Subject == "" {
|
||||||
|
t.Error("note: empty Subject")
|
||||||
|
}
|
||||||
|
if n.From.Name == "" {
|
||||||
|
t.Error("note: empty From.Name")
|
||||||
|
}
|
||||||
|
if n.To.Name == "" {
|
||||||
|
t.Error("note: empty To.Name")
|
||||||
|
}
|
||||||
|
if n.BodyText == "" {
|
||||||
|
t.Error("note: empty BodyText")
|
||||||
|
}
|
||||||
|
if n.SentAt.IsZero() {
|
||||||
|
t.Error("note: zero SentAt")
|
||||||
|
}
|
||||||
|
t.Logf("note: subject=%q from=%s to=%s body-len=%d", n.Subject, n.From.Name, n.To.Name, len(n.BodyText))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseSubmissionInboxPage_RealFixture parses the captured
|
||||||
|
// /msg/submissions/ page (the "new stuff from people you watch" feed) and
|
||||||
|
// asserts: items are extracted, dates are lifted from the date-divider,
|
||||||
|
// authors are populated from figcaptions, and the cursor link is found.
|
||||||
|
func TestParseSubmissionInboxPage_RealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "msg_submissions.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
items, nextURL := parseSubmissionInboxPage(doc, false)
|
||||||
|
t.Logf("inbox: %d items, nextURL=%q", len(items), nextURL)
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Fatal("inbox fixture: no items parsed")
|
||||||
|
}
|
||||||
|
withAuthor := 0
|
||||||
|
withDate := 0
|
||||||
|
for _, it := range items {
|
||||||
|
if it.ID == 0 {
|
||||||
|
t.Errorf("inbox item: ID == 0")
|
||||||
|
}
|
||||||
|
if it.Author.Name != "" {
|
||||||
|
withAuthor++
|
||||||
|
}
|
||||||
|
if !it.PostedAt.IsZero() {
|
||||||
|
withDate++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if withAuthor < len(items)/2 {
|
||||||
|
t.Errorf("inbox: only %d/%d items have Author.Name", withAuthor, len(items))
|
||||||
|
}
|
||||||
|
if withDate < len(items)/2 {
|
||||||
|
t.Errorf("inbox: only %d/%d items have PostedAt group-date lift failing?", withDate, len(items))
|
||||||
|
}
|
||||||
|
if nextURL == "" {
|
||||||
|
t.Log("inbox: no cursor link found (fixture may be on the last page)")
|
||||||
|
} else if !strings.Contains(nextURL, "/msg/submissions/") {
|
||||||
|
t.Errorf("inbox: cursor href looks wrong: %q", nextURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseGalleryPage_BrowseRealFixture verifies parseGalleryPage works
|
||||||
|
// against FA's /browse/ feed the front-page firehose. Same figure[id^=sid-]
|
||||||
|
// structure as user galleries, plus a u-{name} class that encodes the
|
||||||
|
// artist; figcaption still carries the artist link too.
|
||||||
|
func TestParseGalleryPage_BrowseRealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "browse.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
items, hasNext := parseGalleryPage(doc, false)
|
||||||
|
t.Logf("browse: %d items, hasNext=%v", len(items), hasNext)
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Fatal("browse fixture: no items parsed")
|
||||||
|
}
|
||||||
|
// FA serves 72 per page by default; assert we got close to that so we
|
||||||
|
// notice if a selector starts dropping silently.
|
||||||
|
if len(items) < 50 {
|
||||||
|
t.Errorf("browse fixture: only %d items parsed; expected ~72 selector drift?", len(items))
|
||||||
|
}
|
||||||
|
withAuthor := 0
|
||||||
|
for _, it := range items {
|
||||||
|
if it.Author.Name != "" {
|
||||||
|
withAuthor++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if withAuthor < len(items)/2 {
|
||||||
|
t.Errorf("browse: only %d/%d items have Author.Name", withAuthor, len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClassifySystemMessage_NotFoundRealFixture pins the not-found
|
||||||
|
// classifier against FA's real System Error template captured by visiting
|
||||||
|
// a non-existent submission ID.
|
||||||
|
func TestClassifySystemMessage_NotFoundRealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "system_message_not_found.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
got := classifySystemMessage(doc)
|
||||||
|
if !errors.Is(got, ErrNotFound) {
|
||||||
|
t.Fatalf("classifySystemMessage = %v; want ErrNotFound", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity helper: list the document title to help diagnose fixtures whose
|
||||||
|
// content shape doesn't match parser expectations. Useful when adding new
|
||||||
|
// fixtures and triaging which selector to update.
|
||||||
|
func docTitleFor(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
raw := loadFixture(t, name)
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(doc.Find("title").First().Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFixtureTitles is a non-failing diagnostic that prints the <title> of
|
||||||
|
// every captured fixture. Skipped silently when no fixtures are present.
|
||||||
|
func TestFixtureTitles(t *testing.T) {
|
||||||
|
names := []string{
|
||||||
|
"submission.html", "submission_story.html",
|
||||||
|
"user.html", "user_with_shouts.html",
|
||||||
|
"gallery_page1.html", "gallery_page_last.html",
|
||||||
|
"scraps_page1.html", "favorites_page1.html",
|
||||||
|
"journals_listing_page1.html", "journal.html",
|
||||||
|
"comments_submission.html", "comments_journal.html",
|
||||||
|
"system_message_not_found.html",
|
||||||
|
"msg_submissions.html", "msg_others.html", "msg_pms.html", "note_view.html",
|
||||||
|
"search_results.html", "browse.html",
|
||||||
|
}
|
||||||
|
for _, n := range names {
|
||||||
|
t.Run(n, func(t *testing.T) {
|
||||||
|
title := docTitleFor(t, n) // t.Skip fires inside if missing
|
||||||
|
t.Logf("title: %s", title)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
218
search.go
Normal file
218
search.go
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"iter"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SearchType is the submission media type filter on /search/. FA's form
|
||||||
|
// emits a separate `type-{name}=1` checkbox per type; this enum maps to
|
||||||
|
// those field names verbatim.
|
||||||
|
type SearchType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SearchTypeArt SearchType = "art"
|
||||||
|
SearchTypeMusic SearchType = "music"
|
||||||
|
SearchTypeFlash SearchType = "flash"
|
||||||
|
SearchTypeStory SearchType = "story"
|
||||||
|
SearchTypePhoto SearchType = "photo"
|
||||||
|
SearchTypePoetry SearchType = "poetry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SearchMode controls how FA interprets the query string: AND all terms,
|
||||||
|
// OR any term, or its "extended" syntax (the default in the web UI).
|
||||||
|
type SearchMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SearchModeAll SearchMode = "all"
|
||||||
|
SearchModeAny SearchMode = "any"
|
||||||
|
SearchModeExtended SearchMode = "extended"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SearchOrder is the result-sort key.
|
||||||
|
type SearchOrder string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SearchOrderRelevancy SearchOrder = "relevancy"
|
||||||
|
SearchOrderDate SearchOrder = "date"
|
||||||
|
SearchOrderPopularity SearchOrder = "popularity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SearchRange is the "posted within" window. SearchRangeManual lets the
|
||||||
|
// caller specify [SearchOptions.RangeFrom] / [SearchOptions.RangeTo].
|
||||||
|
type SearchRange string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SearchRange1Day SearchRange = "1day"
|
||||||
|
SearchRange3Days SearchRange = "3days"
|
||||||
|
SearchRange7Days SearchRange = "7days"
|
||||||
|
SearchRange30Days SearchRange = "30days"
|
||||||
|
SearchRange90Days SearchRange = "90days"
|
||||||
|
SearchRange1Year SearchRange = "1year"
|
||||||
|
SearchRange3Years SearchRange = "3years"
|
||||||
|
SearchRange5Years SearchRange = "5years"
|
||||||
|
SearchRangeAll SearchRange = "all"
|
||||||
|
SearchRangeManual SearchRange = "manual"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SearchOptions is the full filter configuration for [Client.Search].
|
||||||
|
// Zero-value defaults match what the web UI shows: extended-mode query,
|
||||||
|
// relevancy-desc order, 5-year window, 72/page, all ratings and types
|
||||||
|
// enabled. Override any field to narrow the search.
|
||||||
|
type SearchOptions struct {
|
||||||
|
// Ratings restricts results to these rating levels. nil/empty = all
|
||||||
|
// three (general + mature + adult), matching the web default.
|
||||||
|
Ratings []Rating
|
||||||
|
|
||||||
|
// Types restricts results to these media types. nil/empty = all six.
|
||||||
|
Types []SearchType
|
||||||
|
|
||||||
|
// Mode selects all-words / any-words / extended. Empty defaults to
|
||||||
|
// extended (FA's web default).
|
||||||
|
Mode SearchMode
|
||||||
|
|
||||||
|
// OrderBy is the sort key. Empty defaults to relevancy.
|
||||||
|
OrderBy SearchOrder
|
||||||
|
|
||||||
|
// OrderAsc swaps order direction. Default is descending.
|
||||||
|
OrderAsc bool
|
||||||
|
|
||||||
|
// Range is the time window. Empty defaults to 5years (FA's default).
|
||||||
|
Range SearchRange
|
||||||
|
|
||||||
|
// RangeFrom / RangeTo are only consulted when Range == SearchRangeManual.
|
||||||
|
RangeFrom time.Time
|
||||||
|
RangeTo time.Time
|
||||||
|
|
||||||
|
// PerPage is the page size. Allowed values on the web UI are 24/36/48/60/72.
|
||||||
|
// Zero defaults to 72.
|
||||||
|
PerPage int
|
||||||
|
|
||||||
|
// StartPage is the 1-based page to begin iteration on. Zero/1 = first page.
|
||||||
|
StartPage int
|
||||||
|
|
||||||
|
// MaxPages bounds the number of pages the iterator will request. Zero
|
||||||
|
// = unbounded; iteration stops when FA serves an empty page or omits
|
||||||
|
// the "Next" link.
|
||||||
|
MaxPages int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search runs a /search/?q=... query and returns an iterator over the
|
||||||
|
// matching submissions. Each yielded *Submission carries ID, Title,
|
||||||
|
// Author, ThumbURL, and Rating from the search results page (call
|
||||||
|
// [Client.GetSubmission] with the ID for the full record).
|
||||||
|
//
|
||||||
|
// Pagination follows FA's "Next" anchor page-numbered GET, capped by
|
||||||
|
// [SearchOptions.MaxPages] when set.
|
||||||
|
//
|
||||||
|
// Search works anonymously for most queries; some adult-content searches
|
||||||
|
// require login and will surface as [ErrUnauthorized] via the system-
|
||||||
|
// message classifier.
|
||||||
|
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) iter.Seq2[*Submission, error] {
|
||||||
|
return func(yield func(*Submission, error) bool) {
|
||||||
|
page := opts.StartPage
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
pagesFetched := 0
|
||||||
|
for {
|
||||||
|
if opts.MaxPages > 0 && pagesFetched >= opts.MaxPages {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageURL := buildSearchURL(query, page, opts)
|
||||||
|
var (
|
||||||
|
items []*Submission
|
||||||
|
hasNext bool
|
||||||
|
)
|
||||||
|
err := c.fetch(ctx, pageURL, func(doc *goquery.Document) error {
|
||||||
|
items, hasNext = parseSearchResults(doc, c.cfg.jsonListings)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
yield(nil, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pagesFetched++
|
||||||
|
if len(items) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, s := range items {
|
||||||
|
if !yield(s, nil) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasNext {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSearchURL constructs a /search/ URL with the query plus every
|
||||||
|
// non-default filter field the web UI sends. The form does method=GET so
|
||||||
|
// the encoded params land directly in the URL.
|
||||||
|
func buildSearchURL(query string, page int, opts SearchOptions) string {
|
||||||
|
v := url.Values{}
|
||||||
|
v.Set("q", query)
|
||||||
|
if page > 1 {
|
||||||
|
v.Set("page", strconv.Itoa(page))
|
||||||
|
}
|
||||||
|
if opts.PerPage > 0 {
|
||||||
|
v.Set("perpage", strconv.Itoa(opts.PerPage))
|
||||||
|
}
|
||||||
|
if opts.OrderBy != "" {
|
||||||
|
v.Set("order-by", string(opts.OrderBy))
|
||||||
|
}
|
||||||
|
if opts.OrderAsc {
|
||||||
|
v.Set("order-direction", "asc")
|
||||||
|
}
|
||||||
|
if opts.Mode != "" {
|
||||||
|
v.Set("mode", string(opts.Mode))
|
||||||
|
}
|
||||||
|
if opts.Range != "" {
|
||||||
|
v.Set("range", string(opts.Range))
|
||||||
|
}
|
||||||
|
if opts.Range == SearchRangeManual {
|
||||||
|
if !opts.RangeFrom.IsZero() {
|
||||||
|
v.Set("range_from", opts.RangeFrom.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
if !opts.RangeTo.IsZero() {
|
||||||
|
v.Set("range_to", opts.RangeTo.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ratings: nil means all three are sent (FA's default state).
|
||||||
|
ratings := opts.Ratings
|
||||||
|
if len(ratings) == 0 {
|
||||||
|
ratings = []Rating{RatingGeneral, RatingMature, RatingAdult}
|
||||||
|
}
|
||||||
|
for _, r := range ratings {
|
||||||
|
switch r {
|
||||||
|
case RatingGeneral:
|
||||||
|
v.Set("rating-general", "1")
|
||||||
|
case RatingMature:
|
||||||
|
v.Set("rating-mature", "1")
|
||||||
|
case RatingAdult:
|
||||||
|
v.Set("rating-adult", "1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types: nil means all six. Send only the ones the caller requested.
|
||||||
|
types := opts.Types
|
||||||
|
if len(types) == 0 {
|
||||||
|
types = []SearchType{SearchTypeArt, SearchTypeMusic, SearchTypeFlash, SearchTypeStory, SearchTypePhoto, SearchTypePoetry}
|
||||||
|
}
|
||||||
|
for _, t := range types {
|
||||||
|
v.Set("type-"+string(t), "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls.Host + "/search/?" + v.Encode()
|
||||||
|
}
|
||||||
38
search_parser.go
Normal file
38
search_parser.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseSearchResults walks /search/ output, returning each result and
|
||||||
|
// whether a "Next" page link exists.
|
||||||
|
//
|
||||||
|
// FA renders results inside <section id="gallery-search-results"> with the
|
||||||
|
// same <figure id="sid-…"> shape as gallery/browse/inbox, so the per-item
|
||||||
|
// extraction reuses parseGalleryFigure. Pagination is page-numbered (GET
|
||||||
|
// ?page=N) and the Next anchor is in <div class="pagination"> at the top
|
||||||
|
// (and bottom) of the results.
|
||||||
|
//
|
||||||
|
// useJSON controls the experimental JSON-first merge see parseGalleryPage.
|
||||||
|
func parseSearchResults(doc *goquery.Document, useJSON bool) (items []*Submission, hasNext bool) {
|
||||||
|
var jsonData listingJSONMap
|
||||||
|
if useJSON {
|
||||||
|
jsonData = readListingJSON(doc)
|
||||||
|
}
|
||||||
|
doc.Find("section#gallery-search-results figure[id^=sid-]").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
if s := parseGalleryFigure(sel, jsonData); s != nil {
|
||||||
|
items = append(items, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.Find("div.pagination a.button").EachWithBreak(func(_ int, a *goquery.Selection) bool {
|
||||||
|
if strings.EqualFold(trimText(a), "next") {
|
||||||
|
hasNext = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return items, hasNext
|
||||||
|
}
|
||||||
120
search_test.go
Normal file
120
search_test.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBuildSearchURL_DefaultsSendAllRatingsAndTypes asserts that an empty
|
||||||
|
// SearchOptions encodes every rating and every type as enabled, matching
|
||||||
|
// FA's web-form default state.
|
||||||
|
func TestBuildSearchURL_DefaultsSendAllRatingsAndTypes(t *testing.T) {
|
||||||
|
got := buildSearchURL("dragon", 1, SearchOptions{})
|
||||||
|
u, err := url.Parse(got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
if q.Get("q") != "dragon" {
|
||||||
|
t.Errorf("q = %q", q.Get("q"))
|
||||||
|
}
|
||||||
|
for _, k := range []string{"rating-general", "rating-mature", "rating-adult"} {
|
||||||
|
if q.Get(k) != "1" {
|
||||||
|
t.Errorf("%s = %q; want 1", k, q.Get(k))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range []string{"type-art", "type-music", "type-flash", "type-story", "type-photo", "type-poetry"} {
|
||||||
|
if q.Get(k) != "1" {
|
||||||
|
t.Errorf("%s = %q; want 1", k, q.Get(k))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if q.Get("page") != "" {
|
||||||
|
t.Errorf("page should be unset on page 1, got %q", q.Get("page"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSearchURL_RatingsSubsetEncodesOnlyRequested(t *testing.T) {
|
||||||
|
got := buildSearchURL("x", 1, SearchOptions{Ratings: []Rating{RatingGeneral}})
|
||||||
|
q := mustQuery(t, got)
|
||||||
|
if q.Get("rating-general") != "1" {
|
||||||
|
t.Errorf("rating-general missing")
|
||||||
|
}
|
||||||
|
if q.Get("rating-mature") != "" || q.Get("rating-adult") != "" {
|
||||||
|
t.Errorf("non-requested ratings should be unset; got mature=%q adult=%q",
|
||||||
|
q.Get("rating-mature"), q.Get("rating-adult"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSearchURL_TypesSubsetEncodesOnlyRequested(t *testing.T) {
|
||||||
|
got := buildSearchURL("x", 1, SearchOptions{Types: []SearchType{SearchTypeArt, SearchTypeStory}})
|
||||||
|
q := mustQuery(t, got)
|
||||||
|
for _, k := range []string{"type-art", "type-story"} {
|
||||||
|
if q.Get(k) != "1" {
|
||||||
|
t.Errorf("%s should be set", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range []string{"type-music", "type-flash", "type-photo", "type-poetry"} {
|
||||||
|
if q.Get(k) != "" {
|
||||||
|
t.Errorf("%s should be unset; got %q", k, q.Get(k))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSearchURL_ManualRangeFormatsDates(t *testing.T) {
|
||||||
|
from := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)
|
||||||
|
to := time.Date(2024, 6, 30, 0, 0, 0, 0, time.UTC)
|
||||||
|
got := buildSearchURL("x", 1, SearchOptions{
|
||||||
|
Range: SearchRangeManual,
|
||||||
|
RangeFrom: from,
|
||||||
|
RangeTo: to,
|
||||||
|
})
|
||||||
|
q := mustQuery(t, got)
|
||||||
|
if q.Get("range") != "manual" {
|
||||||
|
t.Errorf("range = %q", q.Get("range"))
|
||||||
|
}
|
||||||
|
if q.Get("range_from") != "2024-01-15" {
|
||||||
|
t.Errorf("range_from = %q", q.Get("range_from"))
|
||||||
|
}
|
||||||
|
if q.Get("range_to") != "2024-06-30" {
|
||||||
|
t.Errorf("range_to = %q", q.Get("range_to"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSearchURL_OrderAndPage(t *testing.T) {
|
||||||
|
got := buildSearchURL("x", 3, SearchOptions{
|
||||||
|
OrderBy: SearchOrderDate,
|
||||||
|
OrderAsc: true,
|
||||||
|
PerPage: 48,
|
||||||
|
})
|
||||||
|
q := mustQuery(t, got)
|
||||||
|
if q.Get("page") != "3" {
|
||||||
|
t.Errorf("page = %q", q.Get("page"))
|
||||||
|
}
|
||||||
|
if q.Get("order-by") != "date" {
|
||||||
|
t.Errorf("order-by = %q", q.Get("order-by"))
|
||||||
|
}
|
||||||
|
if q.Get("order-direction") != "asc" {
|
||||||
|
t.Errorf("order-direction = %q", q.Get("order-direction"))
|
||||||
|
}
|
||||||
|
if q.Get("perpage") != "48" {
|
||||||
|
t.Errorf("perpage = %q", q.Get("perpage"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSearchURL_RootPath(t *testing.T) {
|
||||||
|
got := buildSearchURL("x", 1, SearchOptions{})
|
||||||
|
if !strings.HasPrefix(got, "https://www.furaffinity.net/search/?") {
|
||||||
|
t.Errorf("URL prefix wrong: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustQuery(t *testing.T, raw string) url.Values {
|
||||||
|
t.Helper()
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse %q: %v", raw, err)
|
||||||
|
}
|
||||||
|
return u.Query()
|
||||||
|
}
|
||||||
108
sfw_test.go
Normal file
108
sfw_test.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSFW_CookieOnOutboundRequest sets up an httptest server that records
|
||||||
|
// the Cookie header it received, then asserts each SFW mode produces the
|
||||||
|
// expected `sfw=…` cookie value (or none, for SFWAuto).
|
||||||
|
func TestSFW_CookieOnOutboundRequest(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
mode SFWMode
|
||||||
|
wantSFW string // "" = expect no sfw cookie at all
|
||||||
|
}{
|
||||||
|
{"auto omits cookie", SFWAuto, ""},
|
||||||
|
{"on sets sfw=1", SFWOn, "1"},
|
||||||
|
{"off sets sfw=0", SFWOff, "0"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var seen string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if c, err := r.Cookie("sfw"); err == nil {
|
||||||
|
seen = c.Value
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = w.Write([]byte(`<html><body>
|
||||||
|
<div class="submission-title"><h2>OK</h2></div>
|
||||||
|
<div class="submission-description-artist">
|
||||||
|
<a href="/user/x/"><img class="submission-user-icon avatar" src="/a.png"/></a>
|
||||||
|
<div>
|
||||||
|
<div class="submission-title"><h2>OK</h2></div>
|
||||||
|
<div><span class="c-usernameBlockSimple__displayName" title="x">x</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<img id="submissionImg" src="/file.png" data-fullview-src="/file.png" data-preview-src="/thumb.png"/>
|
||||||
|
</body></html>`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
target, _ := url.Parse(srv.URL)
|
||||||
|
hc := &http.Client{
|
||||||
|
Transport: &rewritingRT{target: target, base: http.DefaultTransport},
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
client := New(
|
||||||
|
WithHTTPClient(hc),
|
||||||
|
WithRateLimit(time.Microsecond, 16),
|
||||||
|
WithMaxRetries(0),
|
||||||
|
WithSFW(tc.mode),
|
||||||
|
)
|
||||||
|
|
||||||
|
if _, err := client.GetSubmission(context.Background(), 1); err != nil {
|
||||||
|
t.Fatalf("GetSubmission: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if seen != tc.wantSFW {
|
||||||
|
t.Errorf("sfw cookie sent = %q; want %q", seen, tc.wantSFW)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSFW_CookieDoesNotClobberAuth confirms that enabling SFW does not
|
||||||
|
// wipe out the a/b session cookies on the same jar both should ride on
|
||||||
|
// the same request.
|
||||||
|
func TestSFW_CookieDoesNotClobberAuth(t *testing.T) {
|
||||||
|
var got string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
got = r.Header.Get("Cookie")
|
||||||
|
_, _ = w.Write([]byte(`<html><body>
|
||||||
|
<div class="submission-description-artist">
|
||||||
|
<div><div class="submission-title"><h2>T</h2></div></div>
|
||||||
|
</div></body></html>`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
target, _ := url.Parse(srv.URL)
|
||||||
|
hc := &http.Client{
|
||||||
|
Transport: &rewritingRT{target: target, base: http.DefaultTransport},
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
client := New(
|
||||||
|
WithHTTPClient(hc),
|
||||||
|
WithRateLimit(time.Microsecond, 16),
|
||||||
|
WithMaxRetries(0),
|
||||||
|
WithCookies(Cookies{A: "aaa", B: "bbb"}),
|
||||||
|
WithSFW(SFWOn),
|
||||||
|
)
|
||||||
|
|
||||||
|
if _, err := client.GetSubmission(context.Background(), 1); err != nil {
|
||||||
|
t.Fatalf("GetSubmission: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, want := range []string{"a=aaa", "b=bbb", "sfw=1"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("Cookie header %q missing %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
98
submission.go
Normal file
98
submission.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Submission is a fully resolved FA submission as seen on /view/{id}/.
|
||||||
|
type Submission struct {
|
||||||
|
ID SubmissionID
|
||||||
|
Title string
|
||||||
|
Author UserRef
|
||||||
|
PostedAt time.Time
|
||||||
|
Rating Rating
|
||||||
|
Category Category
|
||||||
|
Type Type
|
||||||
|
Species Species
|
||||||
|
Gender Gender
|
||||||
|
Description string // raw HTML; sanitise before rendering to a browser
|
||||||
|
DescriptionText string // plaintext convenience
|
||||||
|
Tags []string
|
||||||
|
FileURL string // absolute CDN URL; pass to Download
|
||||||
|
ThumbURL string
|
||||||
|
Width int // 0 if unknown / non-image
|
||||||
|
Height int
|
||||||
|
Stats SubmissionStats
|
||||||
|
Folders []FolderRef
|
||||||
|
Prev SubmissionID // 0 if this is the oldest in the gallery
|
||||||
|
Next SubmissionID // 0 if this is the newest
|
||||||
|
|
||||||
|
// Favorited reports whether the authenticated viewer has favorited this
|
||||||
|
// submission. It is true only when the page was fetched with valid
|
||||||
|
// cookies and FA rendered the "−Fav" (/unfav/) link. An anonymous fetch
|
||||||
|
// always yields false.
|
||||||
|
Favorited bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSubmission fetches the submission with the given numeric ID.
|
||||||
|
// Returns [ErrNotFound] if FA renders a "submission not found" system message,
|
||||||
|
// [ErrUnauthorized] for restricted-visibility submissions when called
|
||||||
|
// without valid cookies, or a wrapped parse error if the markup has shifted.
|
||||||
|
func (c *Client) GetSubmission(ctx context.Context, id SubmissionID) (*Submission, error) {
|
||||||
|
if id <= 0 {
|
||||||
|
return nil, fmt.Errorf("fa: GetSubmission: id must be > 0")
|
||||||
|
}
|
||||||
|
var out *Submission
|
||||||
|
err := c.fetch(ctx, urls.Submission(int64(id)), func(doc *goquery.Document) error {
|
||||||
|
s, err := parseSubmission(id, doc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = s
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download streams the submission's main file from the CDN into w. The same
|
||||||
|
// rate limiter that paces /view/ fetches paces CDN fetches, so an in-flight
|
||||||
|
// gallery iteration will yield correctly when Download is interleaved.
|
||||||
|
//
|
||||||
|
// Returns the number of bytes written. Errors from the writer are wrapped
|
||||||
|
// as-is; HTTP errors come back as [*HTTPError].
|
||||||
|
func (c *Client) Download(ctx context.Context, sub *Submission, w io.Writer) (int64, error) {
|
||||||
|
if sub == nil {
|
||||||
|
return 0, errors.New("fa: Download: nil submission")
|
||||||
|
}
|
||||||
|
if sub.FileURL == "" {
|
||||||
|
return 0, errors.New("fa: Download: submission has no FileURL")
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sub.FileURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// CDN fetches share the same rate-limited transport as page fetches —
|
||||||
|
// see RoundTrip in transport.go where the limiter gates every request.
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
return 0, &HTTPError{StatusCode: resp.StatusCode, URL: sub.FileURL}
|
||||||
|
}
|
||||||
|
return io.Copy(w, resp.Body)
|
||||||
|
}
|
||||||
266
submission_parser.go
Normal file
266
submission_parser.go
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseSubmission lifts a [Submission] out of a /view/{id}/ document. The
|
||||||
|
// selectors target FA's beta theme as captured in testdata/html/submission.html.
|
||||||
|
//
|
||||||
|
// FA's beta page renders submission metadata as two parallel <span> columns
|
||||||
|
// inside .submission-content-stats labels in the first, values in the
|
||||||
|
// second so this parser pairs them up positionally rather than scanning
|
||||||
|
// label-then-value rows.
|
||||||
|
func parseSubmission(id SubmissionID, doc *goquery.Document) (*Submission, error) {
|
||||||
|
s := &Submission{ID: id}
|
||||||
|
|
||||||
|
// Resolve the canonical ID from the og:url meta tag when caller passed 0
|
||||||
|
// (e.g. the real-fixture test). Lets the parser stand on its own.
|
||||||
|
if s.ID == 0 {
|
||||||
|
if og := trimAttr(doc.Find(`meta[property="og:url"]`).First(), "content"); og != "" {
|
||||||
|
if n := extractIntFromHref(og); n > 0 {
|
||||||
|
s.ID = SubmissionID(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Author scoping: there can be multiple .iconusername references on the
|
||||||
|
// page (e.g. inside the description). The submission's true author lives
|
||||||
|
// inside .submission-description-artist.
|
||||||
|
authorBox := doc.Find("div.submission-description-artist").First()
|
||||||
|
|
||||||
|
// Title.
|
||||||
|
s.Title = strings.TrimSpace(authorBox.Find("div.submission-title h2").First().Text())
|
||||||
|
if s.Title == "" {
|
||||||
|
s.Title = strings.TrimSpace(doc.Find("div.submission-title h2").First().Text())
|
||||||
|
}
|
||||||
|
if s.Title == "" {
|
||||||
|
// Surface what FA actually served so the caller can tell the
|
||||||
|
// difference between a CF challenge, an SFW guard, a deleted
|
||||||
|
// submission, and a real markup-drift bug.
|
||||||
|
pageTitle := strings.TrimSpace(doc.Find("title").First().Text())
|
||||||
|
return nil, fmt.Errorf("%w: submission %d: missing title (page <title>=%q)",
|
||||||
|
ErrParse, id, pageTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Author.
|
||||||
|
if authorBox.Length() > 0 {
|
||||||
|
avatarLink := authorBox.Find("a[href^='/user/'] img").First()
|
||||||
|
nameSpan := authorBox.Find(".c-usernameBlockSimple__displayName").First()
|
||||||
|
s.Author = UserRef{
|
||||||
|
DisplayName: trimText(nameSpan),
|
||||||
|
AvatarURL: urls.AbsoluteCDN(trimAttr(avatarLink, "src")),
|
||||||
|
}
|
||||||
|
// Prefer the title attr (URL-safe login) when present; fall back to href.
|
||||||
|
if t := strings.TrimSpace(trimAttr(nameSpan, "title")); t != "" {
|
||||||
|
s.Author.Name = strings.ToLower(t)
|
||||||
|
}
|
||||||
|
if s.Author.Name == "" {
|
||||||
|
href, _ := authorBox.Find("a[href^='/user/']").First().Attr("href")
|
||||||
|
if parts := strings.Split(strings.Trim(href, "/"), "/"); len(parts) >= 2 {
|
||||||
|
s.Author.Name = strings.ToLower(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posted date popup_date carries authoritative data-time.
|
||||||
|
s.PostedAt = parsePopupDate(authorBox.Find("span.popup_date").First())
|
||||||
|
if s.PostedAt.IsZero() {
|
||||||
|
s.PostedAt = parsePopupDate(doc.Find("span.popup_date").First())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rating div with class c-contentRating--{general,mature,adult} in the
|
||||||
|
// page stats panel; fall back to legacy .rating-box for older markup.
|
||||||
|
doc.Find("div.submission-page-stats div[class*='c-contentRating--']").EachWithBreak(func(_ int, sel *goquery.Selection) bool {
|
||||||
|
s.Rating = ParseRating(trimText(sel))
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if s.Rating == "" {
|
||||||
|
doc.Find(".rating-box").EachWithBreak(func(_ int, sel *goquery.Selection) bool {
|
||||||
|
s.Rating = ParseRating(trimText(sel))
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats .submission-page-stats > div[title=...] each holds <div>N</div>
|
||||||
|
// <div class="highlight">Label</div>.
|
||||||
|
doc.Find("div.submission-page-stats > div[title]").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
title := strings.ToLower(trimAttr(sel, "title"))
|
||||||
|
val := parseStatNumber(trimText(sel.Find("div").First()))
|
||||||
|
switch title {
|
||||||
|
case "views":
|
||||||
|
s.Stats.Views = val
|
||||||
|
case "favorites":
|
||||||
|
s.Stats.Favorites = val
|
||||||
|
case "comments":
|
||||||
|
s.Stats.Comments = val
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Category / Theme / Species / Resolution / File Size are two parallel
|
||||||
|
// span columns inside .submission-content-stats. Pair them up by index.
|
||||||
|
statsBlock := doc.Find("div.submission-content-stats").First()
|
||||||
|
if statsBlock.Length() > 0 {
|
||||||
|
var labels []string
|
||||||
|
statsBlock.Find("span.highlight > span").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
labels = append(labels, strings.ToLower(strings.TrimSpace(sel.Text())))
|
||||||
|
})
|
||||||
|
var values []string
|
||||||
|
statsBlock.ChildrenFiltered("span").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
if class, _ := sel.Attr("class"); strings.Contains(class, "highlight") {
|
||||||
|
return // skip the labels column
|
||||||
|
}
|
||||||
|
sel.ChildrenFiltered("span").Each(func(_ int, inner *goquery.Selection) {
|
||||||
|
values = append(values, strings.TrimSpace(inner.Text()))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
for i := 0; i < len(labels) && i < len(values); i++ {
|
||||||
|
switch labels[i] {
|
||||||
|
case "category":
|
||||||
|
s.Category = Category(values[i])
|
||||||
|
case "type", "theme":
|
||||||
|
s.Type = Type(values[i])
|
||||||
|
case "species":
|
||||||
|
s.Species = Species(values[i])
|
||||||
|
case "gender":
|
||||||
|
s.Gender = Gender(values[i])
|
||||||
|
case "resolution":
|
||||||
|
if w, h, ok := parseResolution(values[i]); ok {
|
||||||
|
s.Width, s.Height = w, h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description section.submission-description holds the body inside
|
||||||
|
// .section-body > .submission-description-text.
|
||||||
|
descBody := doc.Find("section.submission-description div.submission-description-text").First()
|
||||||
|
if descBody.Length() == 0 {
|
||||||
|
descBody = doc.Find("div.submission-description").First()
|
||||||
|
}
|
||||||
|
s.Description = htmlOf(descBody)
|
||||||
|
s.DescriptionText = strings.TrimSpace(descBody.Text())
|
||||||
|
|
||||||
|
// Tags anchors inside .submission-tags whose href targets the search.
|
||||||
|
// Tag-block helper anchors and invalid system tags are filtered out.
|
||||||
|
doc.Find("div.submission-tags span.tags a[href*='/search/']").Each(func(_ int, a *goquery.Selection) {
|
||||||
|
t := strings.TrimSpace(a.Text())
|
||||||
|
if t != "" {
|
||||||
|
s.Tags = append(s.Tags, t)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// File URL FA renders a "Download" button in #submission-options that
|
||||||
|
// links to the canonical file for *every* submission type. For visual
|
||||||
|
// art it equals the #submissionImg source; for stories and music it's
|
||||||
|
// the only correct source, because FA injects a generated thumbnail
|
||||||
|
// (e.g. ".thumbnail.<name>.docx.gif") into #submissionImg there. So the
|
||||||
|
// Download button is authoritative; #submissionImg is only a fallback.
|
||||||
|
doc.Find("div#submission-options a").EachWithBreak(func(_ int, a *goquery.Selection) bool {
|
||||||
|
if strings.EqualFold(trimText(a), "download") {
|
||||||
|
s.FileURL = urls.AbsoluteCDN(trimAttr(a, "href"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// #submissionImg holds the inline image for visual art, or a generated
|
||||||
|
// thumbnail for non-image submissions. It always supplies ThumbURL, but
|
||||||
|
// only supplies FileURL when no Download button was found.
|
||||||
|
img := doc.Find("#submissionImg").First()
|
||||||
|
if img.Length() > 0 {
|
||||||
|
s.ThumbURL = urls.AbsoluteCDN(firstNonEmpty(
|
||||||
|
trimAttr(img, "data-preview-src"),
|
||||||
|
trimAttr(img, "src"),
|
||||||
|
))
|
||||||
|
if s.FileURL == "" {
|
||||||
|
s.FileURL = urls.AbsoluteCDN(firstNonEmpty(
|
||||||
|
trimAttr(img, "data-fullview-src"),
|
||||||
|
trimAttr(img, "src"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
// Dimensions also live in width/height attrs on some pages.
|
||||||
|
if w, err := strconv.Atoi(trimAttr(img, "data-fullview-width")); err == nil {
|
||||||
|
s.Width = w
|
||||||
|
}
|
||||||
|
if h, err := strconv.Atoi(trimAttr(img, "data-fullview-height")); err == nil {
|
||||||
|
s.Height = h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Legacy fallback for older themes that predate #submission-options.
|
||||||
|
if s.FileURL == "" {
|
||||||
|
dl := doc.Find("div.submission-controls-upper a[href*='/d.furaffinity.net/'], div.download a, a.download-logged-in").First()
|
||||||
|
s.FileURL = urls.AbsoluteCDN(trimAttr(dl, "href"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prev / Next (FA's minigallery-navigation: "Newer" / "Older").
|
||||||
|
doc.Find("div.minigallery-navigation a").Each(func(_ int, a *goquery.Selection) {
|
||||||
|
href, _ := a.Attr("href")
|
||||||
|
text := strings.ToLower(trimText(a))
|
||||||
|
id := SubmissionID(extractIntFromHref(href))
|
||||||
|
if id == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.Contains(text, "newer"):
|
||||||
|
// "Newer" goes to a more recent submission surface as Prev so
|
||||||
|
// callers walking a gallery can call client.GetSubmission(s.Prev)
|
||||||
|
// to step toward the newest.
|
||||||
|
s.Prev = id
|
||||||
|
case strings.Contains(text, "older"):
|
||||||
|
s.Next = id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Legacy fallback for older themes that still use favorite-nav.
|
||||||
|
if s.Prev == 0 && s.Next == 0 {
|
||||||
|
doc.Find("div.favorite-nav a, .submission-nav a").Each(func(_ int, a *goquery.Selection) {
|
||||||
|
href, _ := a.Attr("href")
|
||||||
|
text := strings.ToLower(trimText(a))
|
||||||
|
id := SubmissionID(extractIntFromHref(href))
|
||||||
|
if id == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.Contains(text, "prev"):
|
||||||
|
s.Prev = id
|
||||||
|
case strings.Contains(text, "next"):
|
||||||
|
s.Next = id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favorite state FA renders exactly one of the "+Fav" / "−Fav" anchors
|
||||||
|
// for an authenticated viewer; the "−Fav" (/unfav/) link means this
|
||||||
|
// submission is currently favorited. findFavLinks (actions.go) already
|
||||||
|
// scrapes both. An anonymous fetch shows neither, leaving Favorited false.
|
||||||
|
if _, unfav := findFavLinks(doc, int64(s.ID)); unfav != "" {
|
||||||
|
s.Favorited = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseResolution splits a "2071 x 1779" string into width and height ints.
|
||||||
|
// Returns ok=false on any malformed input so callers can leave Width/Height
|
||||||
|
// at zero.
|
||||||
|
func parseResolution(s string) (w, h int, ok bool) {
|
||||||
|
parts := strings.Split(s, "x")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
wn, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
hn, err := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return wn, hn, true
|
||||||
|
}
|
||||||
188
submission_parser_test.go
Normal file
188
submission_parser_test.go
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// syntheticSubmissionHTML is a minimal hand-rolled page that exercises every
|
||||||
|
// selector the parser cares about. Real FA HTML differs in ways that
|
||||||
|
// fixture-driven tests will catch; this synthetic input pins the parser
|
||||||
|
// against a stable, deterministic input independent of FA's mood.
|
||||||
|
const syntheticSubmissionHTML = `<html><body>
|
||||||
|
<meta property="og:url" content="https://www.furaffinity.net/view/1234/"/>
|
||||||
|
<section class="submission-description">
|
||||||
|
<div class="submission-description-header">
|
||||||
|
<div class="submission-description-artist">
|
||||||
|
<div><a href="/user/somefurry/"><img class="submission-user-icon avatar" src="//d.example/avatars/somefurry.png"/></a></div>
|
||||||
|
<div>
|
||||||
|
<div class="submission-title"><h2>My Test Submission</h2></div>
|
||||||
|
<div>by <span class="c-usernameBlockSimple"><a href="/user/somefurry/"><span class="c-usernameBlockSimple__displayName" title="somefurry">SomeFurry</span></a></span></div>
|
||||||
|
<div>Posted <span class="popup_date" title="March 17, 2026 04:21:21 PM">5 hours ago</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="submission-description-text"><p>Hello <b>world</b>.</p></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<img id="submissionImg" src="//d.example/art/somefurry/1234.png" data-fullview-src="//d.example/art/somefurry/1234_full.png" data-preview-src="//d.example/art/somefurry/1234_thumb.png" data-fullview-width="1920" data-fullview-height="1080"/>
|
||||||
|
|
||||||
|
<div class="submission-page-stats">
|
||||||
|
<div title="Views"><div>1,234</div><div class="highlight">Views</div></div>
|
||||||
|
<div title="Comments"><div>7</div><div class="highlight">Comments</div></div>
|
||||||
|
<div title="Favorites"><div>56</div><div class="highlight">Favorites</div></div>
|
||||||
|
<div><div class="font-large inline c-contentRating--general">General</div><div class="highlight">Rating</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="submission-content-stats">
|
||||||
|
<span class="highlight">
|
||||||
|
<span>Category</span><span>Theme</span><span>Species</span><span>Gender</span><span>Resolution</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span>Artwork (Digital)</span><span>Digital</span><span>Wolf</span><span>Male</span><span>1920 x 1080</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="submission-tags">
|
||||||
|
<div class="highlight">Keywords</div>
|
||||||
|
<div>
|
||||||
|
<span class="tags"><span><a href="javascript:void(0);" class="tag-block"></a><a href="/search/@keywords wolf">wolf</a></span></span>
|
||||||
|
<span class="tags"><span><a href="javascript:void(0);" class="tag-block"></a><a href="/search/@keywords art">art</a></span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="minigallery-navigation">
|
||||||
|
<a href="/view/1235/">« Newer</a>
|
||||||
|
<a href="/view/1233/">Older »</a>
|
||||||
|
</div>
|
||||||
|
</body></html>`
|
||||||
|
|
||||||
|
func TestParseSubmission_Synthetic(t *testing.T) {
|
||||||
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(syntheticSubmissionHTML))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
sub, err := parseSubmission(1234, doc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseSubmission: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
checks := []struct {
|
||||||
|
name string
|
||||||
|
got any
|
||||||
|
want any
|
||||||
|
}{
|
||||||
|
{"ID", sub.ID, SubmissionID(1234)},
|
||||||
|
{"Title", sub.Title, "My Test Submission"},
|
||||||
|
{"Author.Name", sub.Author.Name, "somefurry"},
|
||||||
|
{"Author.DisplayName", sub.Author.DisplayName, "SomeFurry"},
|
||||||
|
{"Author.AvatarURL", sub.Author.AvatarURL, "https://d.example/avatars/somefurry.png"},
|
||||||
|
{"Rating", sub.Rating, RatingGeneral},
|
||||||
|
{"Category", sub.Category, Category("Artwork (Digital)")},
|
||||||
|
{"Type", sub.Type, Type("Digital")},
|
||||||
|
{"Species", sub.Species, Species("Wolf")},
|
||||||
|
{"Gender", sub.Gender, Gender("Male")},
|
||||||
|
{"FileURL", sub.FileURL, "https://d.example/art/somefurry/1234_full.png"},
|
||||||
|
{"ThumbURL", sub.ThumbURL, "https://d.example/art/somefurry/1234_thumb.png"},
|
||||||
|
{"Width", sub.Width, 1920},
|
||||||
|
{"Height", sub.Height, 1080},
|
||||||
|
{"Stats.Views", sub.Stats.Views, 1234},
|
||||||
|
{"Stats.Favorites", sub.Stats.Favorites, 56},
|
||||||
|
{"Stats.Comments", sub.Stats.Comments, 7},
|
||||||
|
{"Prev (Newer)", sub.Prev, SubmissionID(1235)},
|
||||||
|
{"Next (Older)", sub.Next, SubmissionID(1233)},
|
||||||
|
{"len(Tags)", len(sub.Tags), 2},
|
||||||
|
}
|
||||||
|
for _, c := range checks {
|
||||||
|
if c.got != c.want {
|
||||||
|
t.Errorf("%s = %v; want %v", c.name, c.got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sub.PostedAt.IsZero() && sub.PostedAt.Year() != 2026 {
|
||||||
|
t.Errorf("PostedAt year = %d; want 2026", sub.PostedAt.Year())
|
||||||
|
}
|
||||||
|
if !strings.Contains(sub.Description, "world") {
|
||||||
|
t.Errorf("Description missing expected content: %q", sub.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseSubmission_FavoritedState verifies parseSubmission reports the
|
||||||
|
// authenticated viewer's favorite state. FA renders exactly one of the
|
||||||
|
// "+Fav" (/fav/) or "−Fav" (/unfav/) anchors, matching the viewer's current
|
||||||
|
// state; an anonymous fetch shows neither.
|
||||||
|
func TestParseSubmission_FavoritedState(t *testing.T) {
|
||||||
|
const tmpl = `<html><body>
|
||||||
|
<meta property="og:url" content="https://www.furaffinity.net/view/1234/"/>
|
||||||
|
<section class="submission-description"><div class="submission-description-header">
|
||||||
|
<div class="submission-description-artist"><div></div>
|
||||||
|
<div><div class="submission-title"><h2>T</h2></div></div></div></div></section>
|
||||||
|
<div id="submission-options">%s</div>
|
||||||
|
</body></html>`
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
link string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"favorited shows unfav link", `<a href="/unfav/1234/?key=abc">−Fav</a>`, true},
|
||||||
|
{"not favorited shows fav link", `<a href="/fav/1234/?key=abc">+Fav</a>`, false},
|
||||||
|
{"anonymous shows neither", ``, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(fmt.Sprintf(tmpl, c.link)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
sub, err := parseSubmission(1234, doc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseSubmission: %v", err)
|
||||||
|
}
|
||||||
|
if sub.Favorited != c.want {
|
||||||
|
t.Errorf("Favorited = %v; want %v", sub.Favorited, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSubmission_MissingTitleErrors(t *testing.T) {
|
||||||
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader("<html><body></body></html>"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := parseSubmission(1, doc); err == nil {
|
||||||
|
t.Fatal("expected parse error for missing title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseSubmission_RealFixture runs the parser against a real FA HTML
|
||||||
|
// dump captured by the `fixtures` build tag. Skips cleanly if no fixture
|
||||||
|
// has been recorded.
|
||||||
|
func TestParseSubmission_RealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "submission.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
sub, err := parseSubmission(0, doc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseSubmission(real): %v", err)
|
||||||
|
}
|
||||||
|
// We can't assert exact values against a fixture whose contents we don't
|
||||||
|
// pin in this repo. Instead assert that the load-bearing fields populated.
|
||||||
|
if sub.Title == "" {
|
||||||
|
t.Error("real fixture: Title is empty")
|
||||||
|
}
|
||||||
|
if sub.Author.Name == "" {
|
||||||
|
t.Error("real fixture: Author.Name is empty")
|
||||||
|
}
|
||||||
|
if sub.FileURL == "" {
|
||||||
|
t.Error("real fixture: FileURL is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
65
system_message.go
Normal file
65
system_message.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// classifySystemMessage looks at a fetched document and, when it is one of
|
||||||
|
// FA's gate/error templates rather than a real content page, maps it to the
|
||||||
|
// most specific sentinel error. Returns nil for normal content pages so
|
||||||
|
// parsing continues.
|
||||||
|
//
|
||||||
|
// FA emits at least two distinct gate templates:
|
||||||
|
//
|
||||||
|
// - <title>System Error</title> generic "not found / no permission /
|
||||||
|
// rate limited" page with the human message in a section-body.
|
||||||
|
// - <title>Login Required …</title> auth-gated content (some submissions
|
||||||
|
// are not viewable anonymously). Always maps to ErrUnauthorized.
|
||||||
|
//
|
||||||
|
// We classify by <title> first to pick a template, then by message text
|
||||||
|
// within the section-body when needed. Anything else is left for the
|
||||||
|
// downstream parser to handle.
|
||||||
|
func classifySystemMessage(doc *goquery.Document) error {
|
||||||
|
pageTitle := strings.ToLower(strings.TrimSpace(doc.Find("title").First().Text()))
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(pageTitle, "login required"):
|
||||||
|
return ErrUnauthorized
|
||||||
|
case pageTitle == "system error",
|
||||||
|
strings.HasPrefix(pageTitle, "system error"):
|
||||||
|
// fall through to body classification below
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
headerTitle := trimText(doc.Find("section .section-header h2").First())
|
||||||
|
if headerTitle == "" {
|
||||||
|
headerTitle = trimText(doc.Find("h2").First())
|
||||||
|
}
|
||||||
|
body := strings.TrimSpace(doc.Find("section .section-body").First().Text())
|
||||||
|
if body == "" {
|
||||||
|
body = strings.TrimSpace(doc.Find(".section-body").First().Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
low := strings.ToLower(headerTitle + " " + body)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(low, "not in our database"),
|
||||||
|
strings.Contains(low, "submission not found"),
|
||||||
|
strings.Contains(low, "user not found"),
|
||||||
|
strings.Contains(low, "journal not found"),
|
||||||
|
strings.Contains(low, "page not found"),
|
||||||
|
strings.Contains(low, "no such"):
|
||||||
|
return ErrNotFound
|
||||||
|
case strings.Contains(low, "you must be logged in"),
|
||||||
|
strings.Contains(low, "log in to view"),
|
||||||
|
strings.Contains(low, "permission to view"),
|
||||||
|
strings.Contains(low, "permission to access"):
|
||||||
|
return ErrUnauthorized
|
||||||
|
case strings.Contains(low, "rate limit"),
|
||||||
|
strings.Contains(low, "too many requests"):
|
||||||
|
return ErrRateLimited
|
||||||
|
}
|
||||||
|
return &SystemMessageError{Title: headerTitle, Body: body}
|
||||||
|
}
|
||||||
2150
testdata/html/browse.html
vendored
Normal file
2150
testdata/html/browse.html
vendored
Normal file
File diff suppressed because one or more lines are too long
771
testdata/html/comments_journal.html
vendored
Normal file
771
testdata/html/comments_journal.html
vendored
Normal file
File diff suppressed because one or more lines are too long
1049
testdata/html/comments_submission.html
vendored
Normal file
1049
testdata/html/comments_submission.html
vendored
Normal file
File diff suppressed because one or more lines are too long
457
testdata/html/favorites_page1.html
vendored
Normal file
457
testdata/html/favorites_page1.html
vendored
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="no-js" xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
|
||||||
|
<title>Favorites Gallery for SoXX-TheFennec -- Fur Affinity [dot] net</title>
|
||||||
|
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Fur Affinity | For all things fluff, scaled, and feathered!" />
|
||||||
|
<meta name="keywords" content="fur furry furries fursuit fursuits cosplay brony bronies zootopia scalies kemono anthro anthropormophic art online gallery portfolio" />
|
||||||
|
<meta name="distribution" content="global" />
|
||||||
|
<meta name="copyright" content="Frost Dragon Art LLC" />
|
||||||
|
<meta name="robots" content="noai, noimageai" />
|
||||||
|
|
||||||
|
<link rel="icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
|
||||||
|
<link rel="shortcut icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,500,500i,600,600i,700,700i" rel="stylesheet">
|
||||||
|
<link href="//d.furaffinity.net/media/static/img/icons/bootstrap/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=9; IE=EDGE" />
|
||||||
|
|
||||||
|
<!-- generic -->
|
||||||
|
<meta name="robots" content="noindex" />
|
||||||
|
<!-- og -->
|
||||||
|
<meta property="og:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
|
||||||
|
<!-- twitter -->
|
||||||
|
<meta name="twitter:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var _faurl = {
|
||||||
|
d: '//d.furaffinity.net',
|
||||||
|
s: '//d.furaffinity.net/media/static',
|
||||||
|
a: '//a.furaffinity.net',
|
||||||
|
r: '//rv.furaffinity.net',
|
||||||
|
t: '//t.furaffinity.net',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/common.js?u=2026050915"></script>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/themes/beta/css/ui_theme_dark.css?u=2026050915" id="css-theme" />
|
||||||
|
|
||||||
|
<!-- browser hints -->
|
||||||
|
<link rel="preconnect" href="//t.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="//a.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="//rv.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="https://www15.smartadserver.com" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/censor.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/prototype.1.7.3.min.js" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/script.js?u=2026050915" as="script" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/color_helper.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915" as="script" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/prebid10.26.0.js" as="script" />
|
||||||
|
|
||||||
|
<!-- Playwire Pre-connect <head> links -->
|
||||||
|
<link rel="preconnect" href="https://cdn.intergient.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://cdn.intergi.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://config.playwire.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://securepubads.g.doubleclick.net" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://z.moatads.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://cdn.playwire.com" crossorigin />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<!-- EU request: yes -->
|
||||||
|
<body class="c-bodyColor"
|
||||||
|
id="pageid-redirect" data-static-path="/themes/beta"
|
||||||
|
>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
0; // attempted fix for fouc in ff
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- sidebar -->
|
||||||
|
<div class="mobile-navigation">
|
||||||
|
|
||||||
|
<div class="mobile-nav-container">
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item left">
|
||||||
|
<label for="mobile-menu-nav" class="css-menu-toggle only-one"><img class="burger-menu" src="/themes/beta/img/fa-burger-menu-icon.png"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item center">
|
||||||
|
<a class="mobile-nav-logo" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item right">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-ac-container">
|
||||||
|
<input id="mobile-menu-nav" name="accordion-1" type="checkbox" />
|
||||||
|
<article class="nav-ac-content mobile-menu">
|
||||||
|
|
||||||
|
<div class="mobile-nav-content-container">
|
||||||
|
|
||||||
|
<div class="aligncenter">
|
||||||
|
<a href="/plus/"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"> FA+</a> |
|
||||||
|
<a href="https://shop.furaffinity.net" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"> Shop</a>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<h2><a href="/browse/">Browse</a></h2>
|
||||||
|
<h2><a href="/search/">Search</a></h2>
|
||||||
|
|
||||||
|
<div class="nav-ac-container">
|
||||||
|
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support ▼</h2></label>
|
||||||
|
<input id="mobile-menu-submenu-0" name="accordion-1" type="checkbox" />
|
||||||
|
<article class="nav-ac-content nav-ac-content-dropdown">
|
||||||
|
<a href="/journals/fender">News & Updates</a><br>
|
||||||
|
<a href="/journals/rednef">Events & Spotlight</a><br>
|
||||||
|
<a href="/help/">Help & Support</a><br>
|
||||||
|
<a href="/advertising.html">Advertising</a><br>
|
||||||
|
<a href="/blm">Black Lives Matter</a><br />
|
||||||
|
<a href="/changelog">Changelog</a><br />
|
||||||
|
<a href="/fight_spam">Internet Safety</a><br />
|
||||||
|
|
||||||
|
<h3>Community Walls</h3>
|
||||||
|
<a href="/route/i_was_here">I Was Here</a><br />
|
||||||
|
<a href="/route/banner_museum">Banner Museum</a><br />
|
||||||
|
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
|
||||||
|
|
||||||
|
<h3>SUPPORT FA</h3>
|
||||||
|
<a href="/plus/">Subscribe to FA+ </a><br>
|
||||||
|
<a href="https://shop.furaffinity.net/" target="_blank">FA Merch Store</a>
|
||||||
|
|
||||||
|
|
||||||
|
<h3>RULES & POLICIES</h3>
|
||||||
|
<a href="/tos">Terms of Service</a><br>
|
||||||
|
<a href="/privacy">Privacy</a><br>
|
||||||
|
<a href="/coc">Code of Conduct</a><br>
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
|
||||||
|
<h3>SUPPORT</h3>
|
||||||
|
<a href="/help/#contact">Contact Us</a><br />
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2><div class="inline hideonmobile hideontablet">
|
||||||
|
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline hideondesktop">
|
||||||
|
<a href="/login">Log In</a><br>
|
||||||
|
<a href="/register">Create an Account</a>
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
|
||||||
|
<h2></h2>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<nav id="ddmenu">
|
||||||
|
<div class="mobile-nav navhideondesktop hideonmobile hideontablet">
|
||||||
|
<div class="mobile-nav-logo">
|
||||||
|
<a class="mobile-nav-logo" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-nav-header-item"><a href="/browse/">Browse</a></div>
|
||||||
|
<div class="mobile-nav-header-item"><a href="/search/">Search</a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-icon"></div>
|
||||||
|
|
||||||
|
<ul class="navhideonmobile">
|
||||||
|
<li class="lileft">
|
||||||
|
<div class="lileft hideonmobile" style="vertical-align:middle;line-height:0 !important" >
|
||||||
|
<a class="top-heading" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="lileft">
|
||||||
|
<a class="top-heading" href="#"><div class="sprite-news menu-space-saver hideonmobile"></div>Support</a>
|
||||||
|
<i class="caret"></i>
|
||||||
|
<div class="dropdown dropdown-left ">
|
||||||
|
<div class="dd-inner">
|
||||||
|
<div class="column">
|
||||||
|
<h3>Community</h3>
|
||||||
|
<a href="/journals/fender">News & Updates</a>
|
||||||
|
<a href="/journals/rednef">Events & Spotlight</a>
|
||||||
|
<a href="/help/">Help & Support</a>
|
||||||
|
<a href="/advertising.html">Advertising</a>
|
||||||
|
<a href="/blm/">Black Lives Matter</a>
|
||||||
|
<a href="/changelog">Changelog</a>
|
||||||
|
<a href="/fight_spam">Internet Safety</a>
|
||||||
|
|
||||||
|
<h3>Community Walls</h3>
|
||||||
|
<a href="/route/i_was_here">I Was Here</a>
|
||||||
|
<a href="/route/banner_museum">Banner Museum</a>
|
||||||
|
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
|
||||||
|
|
||||||
|
<h3>Rules & Policies</h3>
|
||||||
|
<a href="/tos">Terms of Service</a>
|
||||||
|
<a href="/privacy">Privacy</a>
|
||||||
|
<a href="/coc">Code of Conduct</a>
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
|
||||||
|
<h3>Support</h3>
|
||||||
|
<a href="/help/#contact">Contact Us</a>
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="/browse/"><div class="sprite-paw menu-space-saver hideonmobile"></div>Browse</a></li>
|
||||||
|
<li class="lileft"><a class="top-heading hideondesktop" href="/search/">Search</a></li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="/submit/"><div class="sprite-upload menu-space-saver hideonmobile"></div> Upload</a></li>
|
||||||
|
|
||||||
|
|
||||||
|
<li class="lileft"><a class="top-heading" href="/plus/" title="FA+"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"><span class="hidebrowselowres"> FA+</span></a></li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="https://shop.furaffinity.net" title="Shop" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"><span class="hidebrowselowres"> Shop</span></a></li>
|
||||||
|
|
||||||
|
<div class="lileft hideonmobile">
|
||||||
|
<form id="searchbox" method="get" action="/search/">
|
||||||
|
<input type="search" name="q" placeholder="SEARCH">
|
||||||
|
<a href="/search"> </a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="no-sub">
|
||||||
|
<span class="top-heading"><div class="inline hideonmobile hideontablet">
|
||||||
|
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline hideondesktop">
|
||||||
|
<a href="/login">Log In</a><br>
|
||||||
|
<a href="/register">Create an Account</a>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(['init_sfw_button', '.sfw-toggle']);
|
||||||
|
</script>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function(){
|
||||||
|
// all menus that should be opened only one at a time
|
||||||
|
$$('.css-menu-toggle.only-one').invoke('observe', 'click', function(evt) {
|
||||||
|
var curr_input = $(evt.findElement('label').getAttribute('for'));
|
||||||
|
curr_input.next('.nav-ac-content').removeClassName('no-transition');
|
||||||
|
if(curr_input.checked === false) {
|
||||||
|
$$('.css-menu-toggle.only-one').each(function(elm){
|
||||||
|
var elm_input = $(elm.getAttribute('for'));
|
||||||
|
if(elm_input.checked === true) {
|
||||||
|
elm_input.next('.nav-ac-content').addClassName('no-transition');
|
||||||
|
elm_input.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="news-block">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="main-window" class="footer-mobile-tweak g-wrapper">
|
||||||
|
|
||||||
|
<div id="header">
|
||||||
|
<!-- site banner -->
|
||||||
|
<site-banner >
|
||||||
|
<map name="banner-map">
|
||||||
|
<area
|
||||||
|
shape="rect"
|
||||||
|
coords="441,144,1042,197"
|
||||||
|
href="https://link.vgen.co/furaffinity"
|
||||||
|
onclick="javascript: return(confirm(`You've clicked on a usemap link that will redirect you to\n\nhttps://link.vgen.co/furaffinity\n\nAre you fine with being redirected?`))"
|
||||||
|
target="_blank" />
|
||||||
|
</map>
|
||||||
|
<a href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.webp" type="image/webp">
|
||||||
|
<img usemap="#banner-map" src="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.jpg">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</site-banner>
|
||||||
|
|
||||||
|
<a name="top"></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="site-content">
|
||||||
|
<!-- /header -->
|
||||||
|
|
||||||
|
<!-- {redirect} -->
|
||||||
|
<div id="standardpage">
|
||||||
|
|
||||||
|
<section class="aligncenter notice-message user-submitted-links">
|
||||||
|
<div class="section-body alignleft">
|
||||||
|
<h2>System Message</h2>
|
||||||
|
|
||||||
|
<div class="redirect-message"><p class="link-override">The owner of this page has elected to make it available to registered users only.<br />To view the contents of this page please <a href="/login?ref=%2Ffavorites%2Fsoxx-thefennec%2F">log in</a> or <a href="/register/">create an account</a>.</p></div>
|
||||||
|
|
||||||
|
<div class="proceed-btn-container">
|
||||||
|
<a class="button standard go" href="javascript: history.back(-1)">Continue »</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- /<div id="site-content"> -->
|
||||||
|
|
||||||
|
<div id="footer">
|
||||||
|
<div class="auto_link footer-links">
|
||||||
|
<a href="/advertising">Advertise</a> |
|
||||||
|
<a href="/plus"><img style="position:relative;top:4px" src="/themes/beta/img/the-golden-pawb.png"> Get FA+</a> |
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a> |
|
||||||
|
<a href="https://shop.furaffinity.net/"><img style="position:relative;top:4px" src="/themes/beta/img/icons/merch_store_icon.png"> Merch Store</a> |
|
||||||
|
<a href="/route/i_was_here">Memorial</a> |
|
||||||
|
<a href="/tos">Terms of Service</a> |
|
||||||
|
<a href="/privacy">Privacy</a> |
|
||||||
|
<a href="/coc">Code of Conduct</a> |
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="footerAds">
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_left"></ins> </div>
|
||||||
|
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<div class="footerAds__slot footerAds__slot--faLogo">
|
||||||
|
<img src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_right"></ins>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="online-stats">
|
||||||
|
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> —
|
||||||
|
3334 <strong>guests</strong>,
|
||||||
|
8900 <strong>registered</strong>
|
||||||
|
and 76340 <strong>other</strong>
|
||||||
|
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
|
||||||
|
</div>
|
||||||
|
<small>Limit bot activity to periods with less than 10k registered users online.</small>
|
||||||
|
|
||||||
|
<br><br>
|
||||||
|
<strong>© 2005-2026 Frost Dragon Art LLC</strong>
|
||||||
|
|
||||||
|
<div class="footnote">
|
||||||
|
Server Time: May 24, 2026 04:31 AM<br />
|
||||||
|
Page generated in 0.01 seconds<br />[ 25.4% PHP, 74.6% SQL ] (18 queries)<br />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function() {
|
||||||
|
var exists = getCookie('sz');
|
||||||
|
var saved = save_viewport_size();
|
||||||
|
if((!exists && saved) || (exists && saved && exists != saved)) {
|
||||||
|
//window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/prebid10.26.0.js"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/censor.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/prototype.1.7.3.min.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/script.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/color_helper.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
|
||||||
|
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var server_timestamp = 1779622303;
|
||||||
|
var client_timestamp = Date.now() / 1000;
|
||||||
|
var server_timestamp_delta = server_timestamp - client_timestamp;
|
||||||
|
var sfw_cookie_name = 'sfw';
|
||||||
|
var news_cookie_name = 'n';
|
||||||
|
|
||||||
|
//
|
||||||
|
document.addEventListener("DOMContentLoaded", (event) => {
|
||||||
|
//
|
||||||
|
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":40,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":56,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":25,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":53,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":49,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":49,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":28,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":74,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":62,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":59,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":65,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":68,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":65,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":68,"tagSize":[320,50]}},"front_page":{"default":{"tagId":77,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":78,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function() {
|
||||||
|
var ddmenuOptions = {
|
||||||
|
menuId: "ddmenu",
|
||||||
|
linkIdToMenuHtml: null,
|
||||||
|
open: "onmouseover", // or "onclick"
|
||||||
|
delay: 1,
|
||||||
|
speed: 1,
|
||||||
|
keysNav: true,
|
||||||
|
license: "2c1f72"
|
||||||
|
};
|
||||||
|
var ddmenu = new Ddmenu(ddmenuOptions);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<!---
|
||||||
|
|\ /|
|
||||||
|
/_^ ^_\
|
||||||
|
\v/
|
||||||
|
|
||||||
|
The fox goes "moo!"
|
||||||
|
--->
|
||||||
|
|
||||||
|
</html>
|
||||||
1610
testdata/html/gallery_page1.html
vendored
Normal file
1610
testdata/html/gallery_page1.html
vendored
Normal file
File diff suppressed because one or more lines are too long
1615
testdata/html/gallery_page_last.html
vendored
Normal file
1615
testdata/html/gallery_page_last.html
vendored
Normal file
File diff suppressed because one or more lines are too long
771
testdata/html/journal.html
vendored
Normal file
771
testdata/html/journal.html
vendored
Normal file
File diff suppressed because one or more lines are too long
458
testdata/html/journals_listing_page1.html
vendored
Normal file
458
testdata/html/journals_listing_page1.html
vendored
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="no-js" xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
|
||||||
|
<title>SoXX-TheFennec's Journals -- Fur Affinity [dot] net</title>
|
||||||
|
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Fur Affinity | For all things fluff, scaled, and feathered!" />
|
||||||
|
<meta name="keywords" content="fur furry furries fursuit fursuits cosplay brony bronies zootopia scalies kemono anthro anthropormophic art online gallery portfolio" />
|
||||||
|
<meta name="distribution" content="global" />
|
||||||
|
<meta name="copyright" content="Frost Dragon Art LLC" />
|
||||||
|
<meta name="robots" content="noai, noimageai" />
|
||||||
|
|
||||||
|
<link rel="icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
|
||||||
|
<link rel="shortcut icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,500,500i,600,600i,700,700i" rel="stylesheet">
|
||||||
|
<link href="//d.furaffinity.net/media/static/img/icons/bootstrap/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=9; IE=EDGE" />
|
||||||
|
|
||||||
|
<!-- generic -->
|
||||||
|
<meta name="robots" content="noindex" />
|
||||||
|
<!-- og -->
|
||||||
|
<meta property="og:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
|
||||||
|
<!-- twitter -->
|
||||||
|
<meta name="twitter:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var _faurl = {
|
||||||
|
d: '//d.furaffinity.net',
|
||||||
|
s: '//d.furaffinity.net/media/static',
|
||||||
|
a: '//a.furaffinity.net',
|
||||||
|
r: '//rv.furaffinity.net',
|
||||||
|
t: '//t.furaffinity.net',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/common.js?u=2026050915"></script>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/themes/beta/css/ui_theme_dark.css?u=2026050915" id="css-theme" />
|
||||||
|
|
||||||
|
<!-- browser hints -->
|
||||||
|
<link rel="preconnect" href="//t.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="//a.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="//rv.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="https://www15.smartadserver.com" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/censor.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/prototype.1.7.3.min.js" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/script.js?u=2026050915" as="script" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/color_helper.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915" as="script" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/prebid10.26.0.js" as="script" />
|
||||||
|
|
||||||
|
<!-- Playwire Pre-connect <head> links -->
|
||||||
|
<link rel="preconnect" href="https://cdn.intergient.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://cdn.intergi.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://config.playwire.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://securepubads.g.doubleclick.net" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://z.moatads.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://cdn.playwire.com" crossorigin />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<!-- EU request: yes -->
|
||||||
|
<body class="c-bodyColor"
|
||||||
|
id="pageid-redirect" data-static-path="/themes/beta"
|
||||||
|
>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
0; // attempted fix for fouc in ff
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- sidebar -->
|
||||||
|
<div class="mobile-navigation">
|
||||||
|
|
||||||
|
<div class="mobile-nav-container">
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item left">
|
||||||
|
<label for="mobile-menu-nav" class="css-menu-toggle only-one"><img class="burger-menu" src="/themes/beta/img/fa-burger-menu-icon.png"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item center">
|
||||||
|
<a class="mobile-nav-logo" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item right">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-ac-container">
|
||||||
|
<input id="mobile-menu-nav" name="accordion-1" type="checkbox" />
|
||||||
|
<article class="nav-ac-content mobile-menu">
|
||||||
|
|
||||||
|
<div class="mobile-nav-content-container">
|
||||||
|
|
||||||
|
<div class="aligncenter">
|
||||||
|
<a href="/plus/"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"> FA+</a> |
|
||||||
|
<a href="https://shop.furaffinity.net" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"> Shop</a>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<h2><a href="/browse/">Browse</a></h2>
|
||||||
|
<h2><a href="/search/">Search</a></h2>
|
||||||
|
|
||||||
|
<div class="nav-ac-container">
|
||||||
|
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support ▼</h2></label>
|
||||||
|
<input id="mobile-menu-submenu-0" name="accordion-1" type="checkbox" />
|
||||||
|
<article class="nav-ac-content nav-ac-content-dropdown">
|
||||||
|
<a href="/journals/fender">News & Updates</a><br>
|
||||||
|
<a href="/journals/rednef">Events & Spotlight</a><br>
|
||||||
|
<a href="/help/">Help & Support</a><br>
|
||||||
|
<a href="/advertising.html">Advertising</a><br>
|
||||||
|
<a href="/blm">Black Lives Matter</a><br />
|
||||||
|
<a href="/changelog">Changelog</a><br />
|
||||||
|
<a href="/fight_spam">Internet Safety</a><br />
|
||||||
|
|
||||||
|
<h3>Community Walls</h3>
|
||||||
|
<a href="/route/i_was_here">I Was Here</a><br />
|
||||||
|
<a href="/route/banner_museum">Banner Museum</a><br />
|
||||||
|
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
|
||||||
|
|
||||||
|
<h3>SUPPORT FA</h3>
|
||||||
|
<a href="/plus/">Subscribe to FA+ </a><br>
|
||||||
|
<a href="https://shop.furaffinity.net/" target="_blank">FA Merch Store</a>
|
||||||
|
|
||||||
|
|
||||||
|
<h3>RULES & POLICIES</h3>
|
||||||
|
<a href="/tos">Terms of Service</a><br>
|
||||||
|
<a href="/privacy">Privacy</a><br>
|
||||||
|
<a href="/coc">Code of Conduct</a><br>
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
|
||||||
|
<h3>SUPPORT</h3>
|
||||||
|
<a href="/help/#contact">Contact Us</a><br />
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2><div class="inline hideonmobile hideontablet">
|
||||||
|
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline hideondesktop">
|
||||||
|
<a href="/login">Log In</a><br>
|
||||||
|
<a href="/register">Create an Account</a>
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
|
||||||
|
<h2></h2>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<nav id="ddmenu">
|
||||||
|
<div class="mobile-nav navhideondesktop hideonmobile hideontablet">
|
||||||
|
<div class="mobile-nav-logo">
|
||||||
|
<a class="mobile-nav-logo" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-nav-header-item"><a href="/browse/">Browse</a></div>
|
||||||
|
<div class="mobile-nav-header-item"><a href="/search/">Search</a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-icon"></div>
|
||||||
|
|
||||||
|
<ul class="navhideonmobile">
|
||||||
|
<li class="lileft">
|
||||||
|
<div class="lileft hideonmobile" style="vertical-align:middle;line-height:0 !important" >
|
||||||
|
<a class="top-heading" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="lileft">
|
||||||
|
<a class="top-heading" href="#"><div class="sprite-news menu-space-saver hideonmobile"></div>Support</a>
|
||||||
|
<i class="caret"></i>
|
||||||
|
<div class="dropdown dropdown-left ">
|
||||||
|
<div class="dd-inner">
|
||||||
|
<div class="column">
|
||||||
|
<h3>Community</h3>
|
||||||
|
<a href="/journals/fender">News & Updates</a>
|
||||||
|
<a href="/journals/rednef">Events & Spotlight</a>
|
||||||
|
<a href="/help/">Help & Support</a>
|
||||||
|
<a href="/advertising.html">Advertising</a>
|
||||||
|
<a href="/blm/">Black Lives Matter</a>
|
||||||
|
<a href="/changelog">Changelog</a>
|
||||||
|
<a href="/fight_spam">Internet Safety</a>
|
||||||
|
|
||||||
|
<h3>Community Walls</h3>
|
||||||
|
<a href="/route/i_was_here">I Was Here</a>
|
||||||
|
<a href="/route/banner_museum">Banner Museum</a>
|
||||||
|
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
|
||||||
|
|
||||||
|
<h3>Rules & Policies</h3>
|
||||||
|
<a href="/tos">Terms of Service</a>
|
||||||
|
<a href="/privacy">Privacy</a>
|
||||||
|
<a href="/coc">Code of Conduct</a>
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
|
||||||
|
<h3>Support</h3>
|
||||||
|
<a href="/help/#contact">Contact Us</a>
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="/browse/"><div class="sprite-paw menu-space-saver hideonmobile"></div>Browse</a></li>
|
||||||
|
<li class="lileft"><a class="top-heading hideondesktop" href="/search/">Search</a></li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="/submit/"><div class="sprite-upload menu-space-saver hideonmobile"></div> Upload</a></li>
|
||||||
|
|
||||||
|
|
||||||
|
<li class="lileft"><a class="top-heading" href="/plus/" title="FA+"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"><span class="hidebrowselowres"> FA+</span></a></li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="https://shop.furaffinity.net" title="Shop" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"><span class="hidebrowselowres"> Shop</span></a></li>
|
||||||
|
|
||||||
|
<div class="lileft hideonmobile">
|
||||||
|
<form id="searchbox" method="get" action="/search/">
|
||||||
|
<input type="search" name="q" placeholder="SEARCH">
|
||||||
|
<a href="/search"> </a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="no-sub">
|
||||||
|
<span class="top-heading"><div class="inline hideonmobile hideontablet">
|
||||||
|
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline hideondesktop">
|
||||||
|
<a href="/login">Log In</a><br>
|
||||||
|
<a href="/register">Create an Account</a>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(['init_sfw_button', '.sfw-toggle']);
|
||||||
|
</script>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function(){
|
||||||
|
// all menus that should be opened only one at a time
|
||||||
|
$$('.css-menu-toggle.only-one').invoke('observe', 'click', function(evt) {
|
||||||
|
var curr_input = $(evt.findElement('label').getAttribute('for'));
|
||||||
|
curr_input.next('.nav-ac-content').removeClassName('no-transition');
|
||||||
|
if(curr_input.checked === false) {
|
||||||
|
$$('.css-menu-toggle.only-one').each(function(elm){
|
||||||
|
var elm_input = $(elm.getAttribute('for'));
|
||||||
|
if(elm_input.checked === true) {
|
||||||
|
elm_input.next('.nav-ac-content').addClassName('no-transition');
|
||||||
|
elm_input.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="news-block">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="main-window" class="footer-mobile-tweak g-wrapper">
|
||||||
|
|
||||||
|
<div id="header">
|
||||||
|
<!-- site banner -->
|
||||||
|
<site-banner >
|
||||||
|
<map name="banner-map">
|
||||||
|
<area
|
||||||
|
shape="rect"
|
||||||
|
coords="441,144,1042,197"
|
||||||
|
href="https://link.vgen.co/furaffinity"
|
||||||
|
onclick="javascript: return(confirm(`You've clicked on a usemap link that will redirect you to\n\nhttps://link.vgen.co/furaffinity\n\nAre you fine with being redirected?`))"
|
||||||
|
target="_blank" />
|
||||||
|
</map>
|
||||||
|
<a href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.webp" type="image/webp">
|
||||||
|
<img usemap="#banner-map" src="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.jpg">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</site-banner>
|
||||||
|
|
||||||
|
<a name="top"></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="site-content">
|
||||||
|
<!-- /header -->
|
||||||
|
|
||||||
|
<!-- {redirect} -->
|
||||||
|
<div id="standardpage">
|
||||||
|
|
||||||
|
<section class="aligncenter notice-message user-submitted-links">
|
||||||
|
<div class="section-body alignleft">
|
||||||
|
<h2>System Message</h2>
|
||||||
|
|
||||||
|
<div class="redirect-message"><p class="link-override">The owner of this page has elected to make it available to registered users only.<br />To view the contents of this page please <a href="/login?ref=%2Fjournals%2Fsoxx-thefennec%2F">log in</a> or <a href="/register/">create an account</a>.</p></div>
|
||||||
|
|
||||||
|
<div class="proceed-btn-container">
|
||||||
|
<a class="button standard go" href="javascript: history.back(-1)">Continue »</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- /<div id="site-content"> -->
|
||||||
|
|
||||||
|
<div id="footer">
|
||||||
|
<div class="auto_link footer-links">
|
||||||
|
<a href="/advertising">Advertise</a> |
|
||||||
|
<a href="/plus"><img style="position:relative;top:4px" src="/themes/beta/img/the-golden-pawb.png"> Get FA+</a> |
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a> |
|
||||||
|
<a href="https://shop.furaffinity.net/"><img style="position:relative;top:4px" src="/themes/beta/img/icons/merch_store_icon.png"> Merch Store</a> |
|
||||||
|
<a href="/route/i_was_here">Memorial</a> |
|
||||||
|
<a href="/tos">Terms of Service</a> |
|
||||||
|
<a href="/privacy">Privacy</a> |
|
||||||
|
<a href="/coc">Code of Conduct</a> |
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="footerAds">
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_left"></ins> </div>
|
||||||
|
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<div class="footerAds__slot footerAds__slot--faLogo">
|
||||||
|
<img src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<ins class="footerAds__slot format--faSmallRectangle jsAdSlot hidden" data-id="footer_right_top"></ins>
|
||||||
|
<ins class="footerAds__slot format--faSmallRectangle jsAdSlot hidden" data-id="footer_right_bottom"></ins>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="online-stats">
|
||||||
|
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> —
|
||||||
|
3334 <strong>guests</strong>,
|
||||||
|
8900 <strong>registered</strong>
|
||||||
|
and 76340 <strong>other</strong>
|
||||||
|
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
|
||||||
|
</div>
|
||||||
|
<small>Limit bot activity to periods with less than 10k registered users online.</small>
|
||||||
|
|
||||||
|
<br><br>
|
||||||
|
<strong>© 2005-2026 Frost Dragon Art LLC</strong>
|
||||||
|
|
||||||
|
<div class="footnote">
|
||||||
|
Server Time: May 24, 2026 04:31 AM<br />
|
||||||
|
Page generated in 0.011 seconds<br />[ 27.3% PHP, 72.7% SQL ] (18 queries)<br />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function() {
|
||||||
|
var exists = getCookie('sz');
|
||||||
|
var saved = save_viewport_size();
|
||||||
|
if((!exists && saved) || (exists && saved && exists != saved)) {
|
||||||
|
//window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/prebid10.26.0.js"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/censor.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/prototype.1.7.3.min.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/script.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/color_helper.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
|
||||||
|
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var server_timestamp = 1779622304;
|
||||||
|
var client_timestamp = Date.now() / 1000;
|
||||||
|
var server_timestamp_delta = server_timestamp - client_timestamp;
|
||||||
|
var sfw_cookie_name = 'sfw';
|
||||||
|
var news_cookie_name = 'n';
|
||||||
|
|
||||||
|
//
|
||||||
|
document.addEventListener("DOMContentLoaded", (event) => {
|
||||||
|
//
|
||||||
|
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":40,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":56,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":25,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":53,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":49,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":49,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":28,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":74,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":62,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":59,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":65,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":68,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":65,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":68,"tagSize":[320,50]}},"front_page":{"default":{"tagId":77,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":78,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function() {
|
||||||
|
var ddmenuOptions = {
|
||||||
|
menuId: "ddmenu",
|
||||||
|
linkIdToMenuHtml: null,
|
||||||
|
open: "onmouseover", // or "onclick"
|
||||||
|
delay: 1,
|
||||||
|
speed: 1,
|
||||||
|
keysNav: true,
|
||||||
|
license: "2c1f72"
|
||||||
|
};
|
||||||
|
var ddmenu = new Ddmenu(ddmenuOptions);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<!---
|
||||||
|
|\ /|
|
||||||
|
/_^ ^_\
|
||||||
|
\v/
|
||||||
|
|
||||||
|
The fox goes "moo!"
|
||||||
|
--->
|
||||||
|
|
||||||
|
</html>
|
||||||
456
testdata/html/msg_others.html
vendored
Normal file
456
testdata/html/msg_others.html
vendored
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="no-js" xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
|
||||||
|
<title>User control panel -- Fur Affinity [dot] net</title>
|
||||||
|
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Fur Affinity | For all things fluff, scaled, and feathered!" />
|
||||||
|
<meta name="keywords" content="fur furry furries fursuit fursuits cosplay brony bronies zootopia scalies kemono anthro anthropormophic art online gallery portfolio" />
|
||||||
|
<meta name="distribution" content="global" />
|
||||||
|
<meta name="copyright" content="Frost Dragon Art LLC" />
|
||||||
|
<meta name="robots" content="noai, noimageai" />
|
||||||
|
|
||||||
|
<link rel="icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
|
||||||
|
<link rel="shortcut icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,500,500i,600,600i,700,700i" rel="stylesheet">
|
||||||
|
<link href="//d.furaffinity.net/media/static/img/icons/bootstrap/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=9; IE=EDGE" />
|
||||||
|
|
||||||
|
<!-- og -->
|
||||||
|
<meta property="og:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
|
||||||
|
<!-- twitter -->
|
||||||
|
<meta name="twitter:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var _faurl = {
|
||||||
|
d: '//d.furaffinity.net',
|
||||||
|
s: '//d.furaffinity.net/media/static',
|
||||||
|
a: '//a.furaffinity.net',
|
||||||
|
r: '//rv.furaffinity.net',
|
||||||
|
t: '//t.furaffinity.net',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/common.js?u=2026050915"></script>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/themes/beta/css/ui_theme_dark.css?u=2026050915" id="css-theme" />
|
||||||
|
|
||||||
|
<!-- browser hints -->
|
||||||
|
<link rel="preconnect" href="//t.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="//a.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="//rv.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="https://www15.smartadserver.com" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/censor.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/prototype.1.7.3.min.js" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/script.js?u=2026050915" as="script" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/color_helper.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915" as="script" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/prebid10.26.0.js" as="script" />
|
||||||
|
|
||||||
|
<!-- Playwire Pre-connect <head> links -->
|
||||||
|
<link rel="preconnect" href="https://cdn.intergient.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://cdn.intergi.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://config.playwire.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://securepubads.g.doubleclick.net" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://z.moatads.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://cdn.playwire.com" crossorigin />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<!-- EU request: yes -->
|
||||||
|
<body class="c-bodyColor"
|
||||||
|
id="pageid-redirect" data-static-path="/themes/beta"
|
||||||
|
>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
0; // attempted fix for fouc in ff
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- sidebar -->
|
||||||
|
<div class="mobile-navigation">
|
||||||
|
|
||||||
|
<div class="mobile-nav-container">
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item left">
|
||||||
|
<label for="mobile-menu-nav" class="css-menu-toggle only-one"><img class="burger-menu" src="/themes/beta/img/fa-burger-menu-icon.png"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item center">
|
||||||
|
<a class="mobile-nav-logo" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item right">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-ac-container">
|
||||||
|
<input id="mobile-menu-nav" name="accordion-1" type="checkbox" />
|
||||||
|
<article class="nav-ac-content mobile-menu">
|
||||||
|
|
||||||
|
<div class="mobile-nav-content-container">
|
||||||
|
|
||||||
|
<div class="aligncenter">
|
||||||
|
<a href="/plus/"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"> FA+</a> |
|
||||||
|
<a href="https://shop.furaffinity.net" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"> Shop</a>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<h2><a href="/browse/">Browse</a></h2>
|
||||||
|
<h2><a href="/search/">Search</a></h2>
|
||||||
|
|
||||||
|
<div class="nav-ac-container">
|
||||||
|
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support ▼</h2></label>
|
||||||
|
<input id="mobile-menu-submenu-0" name="accordion-1" type="checkbox" />
|
||||||
|
<article class="nav-ac-content nav-ac-content-dropdown">
|
||||||
|
<a href="/journals/fender">News & Updates</a><br>
|
||||||
|
<a href="/journals/rednef">Events & Spotlight</a><br>
|
||||||
|
<a href="/help/">Help & Support</a><br>
|
||||||
|
<a href="/advertising.html">Advertising</a><br>
|
||||||
|
<a href="/blm">Black Lives Matter</a><br />
|
||||||
|
<a href="/changelog">Changelog</a><br />
|
||||||
|
<a href="/fight_spam">Internet Safety</a><br />
|
||||||
|
|
||||||
|
<h3>Community Walls</h3>
|
||||||
|
<a href="/route/i_was_here">I Was Here</a><br />
|
||||||
|
<a href="/route/banner_museum">Banner Museum</a><br />
|
||||||
|
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
|
||||||
|
|
||||||
|
<h3>SUPPORT FA</h3>
|
||||||
|
<a href="/plus/">Subscribe to FA+ </a><br>
|
||||||
|
<a href="https://shop.furaffinity.net/" target="_blank">FA Merch Store</a>
|
||||||
|
|
||||||
|
|
||||||
|
<h3>RULES & POLICIES</h3>
|
||||||
|
<a href="/tos">Terms of Service</a><br>
|
||||||
|
<a href="/privacy">Privacy</a><br>
|
||||||
|
<a href="/coc">Code of Conduct</a><br>
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
|
||||||
|
<h3>SUPPORT</h3>
|
||||||
|
<a href="/help/#contact">Contact Us</a><br />
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2><div class="inline hideonmobile hideontablet">
|
||||||
|
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline hideondesktop">
|
||||||
|
<a href="/login">Log In</a><br>
|
||||||
|
<a href="/register">Create an Account</a>
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
|
||||||
|
<h2></h2>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<nav id="ddmenu">
|
||||||
|
<div class="mobile-nav navhideondesktop hideonmobile hideontablet">
|
||||||
|
<div class="mobile-nav-logo">
|
||||||
|
<a class="mobile-nav-logo" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-nav-header-item"><a href="/browse/">Browse</a></div>
|
||||||
|
<div class="mobile-nav-header-item"><a href="/search/">Search</a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-icon"></div>
|
||||||
|
|
||||||
|
<ul class="navhideonmobile">
|
||||||
|
<li class="lileft">
|
||||||
|
<div class="lileft hideonmobile" style="vertical-align:middle;line-height:0 !important" >
|
||||||
|
<a class="top-heading" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="lileft">
|
||||||
|
<a class="top-heading" href="#"><div class="sprite-news menu-space-saver hideonmobile"></div>Support</a>
|
||||||
|
<i class="caret"></i>
|
||||||
|
<div class="dropdown dropdown-left ">
|
||||||
|
<div class="dd-inner">
|
||||||
|
<div class="column">
|
||||||
|
<h3>Community</h3>
|
||||||
|
<a href="/journals/fender">News & Updates</a>
|
||||||
|
<a href="/journals/rednef">Events & Spotlight</a>
|
||||||
|
<a href="/help/">Help & Support</a>
|
||||||
|
<a href="/advertising.html">Advertising</a>
|
||||||
|
<a href="/blm/">Black Lives Matter</a>
|
||||||
|
<a href="/changelog">Changelog</a>
|
||||||
|
<a href="/fight_spam">Internet Safety</a>
|
||||||
|
|
||||||
|
<h3>Community Walls</h3>
|
||||||
|
<a href="/route/i_was_here">I Was Here</a>
|
||||||
|
<a href="/route/banner_museum">Banner Museum</a>
|
||||||
|
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
|
||||||
|
|
||||||
|
<h3>Rules & Policies</h3>
|
||||||
|
<a href="/tos">Terms of Service</a>
|
||||||
|
<a href="/privacy">Privacy</a>
|
||||||
|
<a href="/coc">Code of Conduct</a>
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
|
||||||
|
<h3>Support</h3>
|
||||||
|
<a href="/help/#contact">Contact Us</a>
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="/browse/"><div class="sprite-paw menu-space-saver hideonmobile"></div>Browse</a></li>
|
||||||
|
<li class="lileft"><a class="top-heading hideondesktop" href="/search/">Search</a></li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="/submit/"><div class="sprite-upload menu-space-saver hideonmobile"></div> Upload</a></li>
|
||||||
|
|
||||||
|
|
||||||
|
<li class="lileft"><a class="top-heading" href="/plus/" title="FA+"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"><span class="hidebrowselowres"> FA+</span></a></li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="https://shop.furaffinity.net" title="Shop" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"><span class="hidebrowselowres"> Shop</span></a></li>
|
||||||
|
|
||||||
|
<div class="lileft hideonmobile">
|
||||||
|
<form id="searchbox" method="get" action="/search/">
|
||||||
|
<input type="search" name="q" placeholder="SEARCH">
|
||||||
|
<a href="/search"> </a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="no-sub">
|
||||||
|
<span class="top-heading"><div class="inline hideonmobile hideontablet">
|
||||||
|
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline hideondesktop">
|
||||||
|
<a href="/login">Log In</a><br>
|
||||||
|
<a href="/register">Create an Account</a>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(['init_sfw_button', '.sfw-toggle']);
|
||||||
|
</script>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function(){
|
||||||
|
// all menus that should be opened only one at a time
|
||||||
|
$$('.css-menu-toggle.only-one').invoke('observe', 'click', function(evt) {
|
||||||
|
var curr_input = $(evt.findElement('label').getAttribute('for'));
|
||||||
|
curr_input.next('.nav-ac-content').removeClassName('no-transition');
|
||||||
|
if(curr_input.checked === false) {
|
||||||
|
$$('.css-menu-toggle.only-one').each(function(elm){
|
||||||
|
var elm_input = $(elm.getAttribute('for'));
|
||||||
|
if(elm_input.checked === true) {
|
||||||
|
elm_input.next('.nav-ac-content').addClassName('no-transition');
|
||||||
|
elm_input.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="news-block">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="main-window" class="footer-mobile-tweak g-wrapper">
|
||||||
|
|
||||||
|
<div id="header">
|
||||||
|
<!-- site banner -->
|
||||||
|
<site-banner >
|
||||||
|
<map name="banner-map">
|
||||||
|
<area
|
||||||
|
shape="rect"
|
||||||
|
coords="441,144,1042,197"
|
||||||
|
href="https://link.vgen.co/furaffinity"
|
||||||
|
onclick="javascript: return(confirm(`You've clicked on a usemap link that will redirect you to\n\nhttps://link.vgen.co/furaffinity\n\nAre you fine with being redirected?`))"
|
||||||
|
target="_blank" />
|
||||||
|
</map>
|
||||||
|
<a href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.webp" type="image/webp">
|
||||||
|
<img usemap="#banner-map" src="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.jpg">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</site-banner>
|
||||||
|
|
||||||
|
<a name="top"></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="site-content">
|
||||||
|
<!-- /header -->
|
||||||
|
|
||||||
|
<!-- {redirect} -->
|
||||||
|
<div id="standardpage">
|
||||||
|
|
||||||
|
<section class="aligncenter notice-message user-submitted-links">
|
||||||
|
<div class="section-body alignleft">
|
||||||
|
<h2>System Message</h2>
|
||||||
|
|
||||||
|
<div class="redirect-message">Please log in!</div>
|
||||||
|
|
||||||
|
<div class="proceed-btn-container">
|
||||||
|
<a class="button standard go" href="/login/">Continue »</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- /<div id="site-content"> -->
|
||||||
|
|
||||||
|
<div id="footer">
|
||||||
|
<div class="auto_link footer-links">
|
||||||
|
<a href="/advertising">Advertise</a> |
|
||||||
|
<a href="/plus"><img style="position:relative;top:4px" src="/themes/beta/img/the-golden-pawb.png"> Get FA+</a> |
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a> |
|
||||||
|
<a href="https://shop.furaffinity.net/"><img style="position:relative;top:4px" src="/themes/beta/img/icons/merch_store_icon.png"> Merch Store</a> |
|
||||||
|
<a href="/route/i_was_here">Memorial</a> |
|
||||||
|
<a href="/tos">Terms of Service</a> |
|
||||||
|
<a href="/privacy">Privacy</a> |
|
||||||
|
<a href="/coc">Code of Conduct</a> |
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="footerAds">
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_left"></ins> </div>
|
||||||
|
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<div class="footerAds__slot footerAds__slot--faLogo">
|
||||||
|
<img src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<ins class="footerAds__slot format--faSmallRectangle jsAdSlot hidden" data-id="footer_right_top"></ins>
|
||||||
|
<ins class="footerAds__slot format--faSmallRectangle jsAdSlot hidden" data-id="footer_right_bottom"></ins>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="online-stats">
|
||||||
|
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> —
|
||||||
|
3334 <strong>guests</strong>,
|
||||||
|
8900 <strong>registered</strong>
|
||||||
|
and 76340 <strong>other</strong>
|
||||||
|
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
|
||||||
|
</div>
|
||||||
|
<small>Limit bot activity to periods with less than 10k registered users online.</small>
|
||||||
|
|
||||||
|
<br><br>
|
||||||
|
<strong>© 2005-2026 Frost Dragon Art LLC</strong>
|
||||||
|
|
||||||
|
<div class="footnote">
|
||||||
|
Server Time: May 24, 2026 04:31 AM<br />
|
||||||
|
Page generated in 0.005 seconds<br />[ 36.7% PHP, 63.3% SQL ] (9 queries)<br />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function() {
|
||||||
|
var exists = getCookie('sz');
|
||||||
|
var saved = save_viewport_size();
|
||||||
|
if((!exists && saved) || (exists && saved && exists != saved)) {
|
||||||
|
//window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/prebid10.26.0.js"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/censor.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/prototype.1.7.3.min.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/script.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/color_helper.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
|
||||||
|
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var server_timestamp = 1779622310;
|
||||||
|
var client_timestamp = Date.now() / 1000;
|
||||||
|
var server_timestamp_delta = server_timestamp - client_timestamp;
|
||||||
|
var sfw_cookie_name = 'sfw';
|
||||||
|
var news_cookie_name = 'n';
|
||||||
|
|
||||||
|
//
|
||||||
|
document.addEventListener("DOMContentLoaded", (event) => {
|
||||||
|
//
|
||||||
|
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":40,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":56,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":25,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":53,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":49,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":49,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":28,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":74,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":62,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":59,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":65,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":68,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":65,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":68,"tagSize":[320,50]}},"front_page":{"default":{"tagId":77,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":78,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function() {
|
||||||
|
var ddmenuOptions = {
|
||||||
|
menuId: "ddmenu",
|
||||||
|
linkIdToMenuHtml: null,
|
||||||
|
open: "onmouseover", // or "onclick"
|
||||||
|
delay: 1,
|
||||||
|
speed: 1,
|
||||||
|
keysNav: true,
|
||||||
|
license: "2c1f72"
|
||||||
|
};
|
||||||
|
var ddmenu = new Ddmenu(ddmenuOptions);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<!---
|
||||||
|
|\ /|
|
||||||
|
/_^ ^_\
|
||||||
|
\v/
|
||||||
|
|
||||||
|
The fox goes "moo!"
|
||||||
|
--->
|
||||||
|
|
||||||
|
</html>
|
||||||
455
testdata/html/msg_pms.html
vendored
Normal file
455
testdata/html/msg_pms.html
vendored
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="no-js" xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
|
||||||
|
<title>User control panel -- Fur Affinity [dot] net</title>
|
||||||
|
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Fur Affinity | For all things fluff, scaled, and feathered!" />
|
||||||
|
<meta name="keywords" content="fur furry furries fursuit fursuits cosplay brony bronies zootopia scalies kemono anthro anthropormophic art online gallery portfolio" />
|
||||||
|
<meta name="distribution" content="global" />
|
||||||
|
<meta name="copyright" content="Frost Dragon Art LLC" />
|
||||||
|
<meta name="robots" content="noai, noimageai" />
|
||||||
|
|
||||||
|
<link rel="icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
|
||||||
|
<link rel="shortcut icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,500,500i,600,600i,700,700i" rel="stylesheet">
|
||||||
|
<link href="//d.furaffinity.net/media/static/img/icons/bootstrap/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=9; IE=EDGE" />
|
||||||
|
|
||||||
|
<!-- og -->
|
||||||
|
<meta property="og:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
|
||||||
|
<!-- twitter -->
|
||||||
|
<meta name="twitter:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var _faurl = {
|
||||||
|
d: '//d.furaffinity.net',
|
||||||
|
s: '//d.furaffinity.net/media/static',
|
||||||
|
a: '//a.furaffinity.net',
|
||||||
|
r: '//rv.furaffinity.net',
|
||||||
|
t: '//t.furaffinity.net',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/common.js?u=2026050915"></script>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/themes/beta/css/ui_theme_dark.css?u=2026050915" id="css-theme" />
|
||||||
|
|
||||||
|
<!-- browser hints -->
|
||||||
|
<link rel="preconnect" href="//t.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="//a.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="//rv.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="https://www15.smartadserver.com" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/censor.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/prototype.1.7.3.min.js" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/script.js?u=2026050915" as="script" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/color_helper.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915" as="script" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/prebid10.26.0.js" as="script" />
|
||||||
|
|
||||||
|
<!-- Playwire Pre-connect <head> links -->
|
||||||
|
<link rel="preconnect" href="https://cdn.intergient.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://cdn.intergi.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://config.playwire.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://securepubads.g.doubleclick.net" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://z.moatads.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://cdn.playwire.com" crossorigin />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<!-- EU request: yes -->
|
||||||
|
<body class="c-bodyColor"
|
||||||
|
id="pageid-redirect" data-static-path="/themes/beta"
|
||||||
|
>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
0; // attempted fix for fouc in ff
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- sidebar -->
|
||||||
|
<div class="mobile-navigation">
|
||||||
|
|
||||||
|
<div class="mobile-nav-container">
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item left">
|
||||||
|
<label for="mobile-menu-nav" class="css-menu-toggle only-one"><img class="burger-menu" src="/themes/beta/img/fa-burger-menu-icon.png"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item center">
|
||||||
|
<a class="mobile-nav-logo" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item right">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-ac-container">
|
||||||
|
<input id="mobile-menu-nav" name="accordion-1" type="checkbox" />
|
||||||
|
<article class="nav-ac-content mobile-menu">
|
||||||
|
|
||||||
|
<div class="mobile-nav-content-container">
|
||||||
|
|
||||||
|
<div class="aligncenter">
|
||||||
|
<a href="/plus/"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"> FA+</a> |
|
||||||
|
<a href="https://shop.furaffinity.net" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"> Shop</a>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<h2><a href="/browse/">Browse</a></h2>
|
||||||
|
<h2><a href="/search/">Search</a></h2>
|
||||||
|
|
||||||
|
<div class="nav-ac-container">
|
||||||
|
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support ▼</h2></label>
|
||||||
|
<input id="mobile-menu-submenu-0" name="accordion-1" type="checkbox" />
|
||||||
|
<article class="nav-ac-content nav-ac-content-dropdown">
|
||||||
|
<a href="/journals/fender">News & Updates</a><br>
|
||||||
|
<a href="/journals/rednef">Events & Spotlight</a><br>
|
||||||
|
<a href="/help/">Help & Support</a><br>
|
||||||
|
<a href="/advertising.html">Advertising</a><br>
|
||||||
|
<a href="/blm">Black Lives Matter</a><br />
|
||||||
|
<a href="/changelog">Changelog</a><br />
|
||||||
|
<a href="/fight_spam">Internet Safety</a><br />
|
||||||
|
|
||||||
|
<h3>Community Walls</h3>
|
||||||
|
<a href="/route/i_was_here">I Was Here</a><br />
|
||||||
|
<a href="/route/banner_museum">Banner Museum</a><br />
|
||||||
|
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
|
||||||
|
|
||||||
|
<h3>SUPPORT FA</h3>
|
||||||
|
<a href="/plus/">Subscribe to FA+ </a><br>
|
||||||
|
<a href="https://shop.furaffinity.net/" target="_blank">FA Merch Store</a>
|
||||||
|
|
||||||
|
|
||||||
|
<h3>RULES & POLICIES</h3>
|
||||||
|
<a href="/tos">Terms of Service</a><br>
|
||||||
|
<a href="/privacy">Privacy</a><br>
|
||||||
|
<a href="/coc">Code of Conduct</a><br>
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
|
||||||
|
<h3>SUPPORT</h3>
|
||||||
|
<a href="/help/#contact">Contact Us</a><br />
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2><div class="inline hideonmobile hideontablet">
|
||||||
|
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline hideondesktop">
|
||||||
|
<a href="/login">Log In</a><br>
|
||||||
|
<a href="/register">Create an Account</a>
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
|
||||||
|
<h2></h2>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<nav id="ddmenu">
|
||||||
|
<div class="mobile-nav navhideondesktop hideonmobile hideontablet">
|
||||||
|
<div class="mobile-nav-logo">
|
||||||
|
<a class="mobile-nav-logo" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-nav-header-item"><a href="/browse/">Browse</a></div>
|
||||||
|
<div class="mobile-nav-header-item"><a href="/search/">Search</a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-icon"></div>
|
||||||
|
|
||||||
|
<ul class="navhideonmobile">
|
||||||
|
<li class="lileft">
|
||||||
|
<div class="lileft hideonmobile" style="vertical-align:middle;line-height:0 !important" >
|
||||||
|
<a class="top-heading" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="lileft">
|
||||||
|
<a class="top-heading" href="#"><div class="sprite-news menu-space-saver hideonmobile"></div>Support</a>
|
||||||
|
<i class="caret"></i>
|
||||||
|
<div class="dropdown dropdown-left ">
|
||||||
|
<div class="dd-inner">
|
||||||
|
<div class="column">
|
||||||
|
<h3>Community</h3>
|
||||||
|
<a href="/journals/fender">News & Updates</a>
|
||||||
|
<a href="/journals/rednef">Events & Spotlight</a>
|
||||||
|
<a href="/help/">Help & Support</a>
|
||||||
|
<a href="/advertising.html">Advertising</a>
|
||||||
|
<a href="/blm/">Black Lives Matter</a>
|
||||||
|
<a href="/changelog">Changelog</a>
|
||||||
|
<a href="/fight_spam">Internet Safety</a>
|
||||||
|
|
||||||
|
<h3>Community Walls</h3>
|
||||||
|
<a href="/route/i_was_here">I Was Here</a>
|
||||||
|
<a href="/route/banner_museum">Banner Museum</a>
|
||||||
|
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
|
||||||
|
|
||||||
|
<h3>Rules & Policies</h3>
|
||||||
|
<a href="/tos">Terms of Service</a>
|
||||||
|
<a href="/privacy">Privacy</a>
|
||||||
|
<a href="/coc">Code of Conduct</a>
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
|
||||||
|
<h3>Support</h3>
|
||||||
|
<a href="/help/#contact">Contact Us</a>
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="/browse/"><div class="sprite-paw menu-space-saver hideonmobile"></div>Browse</a></li>
|
||||||
|
<li class="lileft"><a class="top-heading hideondesktop" href="/search/">Search</a></li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="/submit/"><div class="sprite-upload menu-space-saver hideonmobile"></div> Upload</a></li>
|
||||||
|
|
||||||
|
|
||||||
|
<li class="lileft"><a class="top-heading" href="/plus/" title="FA+"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"><span class="hidebrowselowres"> FA+</span></a></li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="https://shop.furaffinity.net" title="Shop" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"><span class="hidebrowselowres"> Shop</span></a></li>
|
||||||
|
|
||||||
|
<div class="lileft hideonmobile">
|
||||||
|
<form id="searchbox" method="get" action="/search/">
|
||||||
|
<input type="search" name="q" placeholder="SEARCH">
|
||||||
|
<a href="/search"> </a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="no-sub">
|
||||||
|
<span class="top-heading"><div class="inline hideonmobile hideontablet">
|
||||||
|
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline hideondesktop">
|
||||||
|
<a href="/login">Log In</a><br>
|
||||||
|
<a href="/register">Create an Account</a>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(['init_sfw_button', '.sfw-toggle']);
|
||||||
|
</script>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function(){
|
||||||
|
// all menus that should be opened only one at a time
|
||||||
|
$$('.css-menu-toggle.only-one').invoke('observe', 'click', function(evt) {
|
||||||
|
var curr_input = $(evt.findElement('label').getAttribute('for'));
|
||||||
|
curr_input.next('.nav-ac-content').removeClassName('no-transition');
|
||||||
|
if(curr_input.checked === false) {
|
||||||
|
$$('.css-menu-toggle.only-one').each(function(elm){
|
||||||
|
var elm_input = $(elm.getAttribute('for'));
|
||||||
|
if(elm_input.checked === true) {
|
||||||
|
elm_input.next('.nav-ac-content').addClassName('no-transition');
|
||||||
|
elm_input.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="news-block">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="main-window" class="footer-mobile-tweak g-wrapper">
|
||||||
|
|
||||||
|
<div id="header">
|
||||||
|
<!-- site banner -->
|
||||||
|
<site-banner >
|
||||||
|
<map name="banner-map">
|
||||||
|
<area
|
||||||
|
shape="rect"
|
||||||
|
coords="441,144,1042,197"
|
||||||
|
href="https://link.vgen.co/furaffinity"
|
||||||
|
onclick="javascript: return(confirm(`You've clicked on a usemap link that will redirect you to\n\nhttps://link.vgen.co/furaffinity\n\nAre you fine with being redirected?`))"
|
||||||
|
target="_blank" />
|
||||||
|
</map>
|
||||||
|
<a href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.webp" type="image/webp">
|
||||||
|
<img usemap="#banner-map" src="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.jpg">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</site-banner>
|
||||||
|
|
||||||
|
<a name="top"></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="site-content">
|
||||||
|
<!-- /header -->
|
||||||
|
|
||||||
|
<!-- {redirect} -->
|
||||||
|
<div id="standardpage">
|
||||||
|
|
||||||
|
<section class="aligncenter notice-message user-submitted-links">
|
||||||
|
<div class="section-body alignleft">
|
||||||
|
<h2>System Message</h2>
|
||||||
|
|
||||||
|
<div class="redirect-message">Please log in!</div>
|
||||||
|
|
||||||
|
<div class="proceed-btn-container">
|
||||||
|
<a class="button standard go" href="/login/">Continue »</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- /<div id="site-content"> -->
|
||||||
|
|
||||||
|
<div id="footer">
|
||||||
|
<div class="auto_link footer-links">
|
||||||
|
<a href="/advertising">Advertise</a> |
|
||||||
|
<a href="/plus"><img style="position:relative;top:4px" src="/themes/beta/img/the-golden-pawb.png"> Get FA+</a> |
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a> |
|
||||||
|
<a href="https://shop.furaffinity.net/"><img style="position:relative;top:4px" src="/themes/beta/img/icons/merch_store_icon.png"> Merch Store</a> |
|
||||||
|
<a href="/route/i_was_here">Memorial</a> |
|
||||||
|
<a href="/tos">Terms of Service</a> |
|
||||||
|
<a href="/privacy">Privacy</a> |
|
||||||
|
<a href="/coc">Code of Conduct</a> |
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="footerAds">
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_left"></ins> </div>
|
||||||
|
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<div class="footerAds__slot footerAds__slot--faLogo">
|
||||||
|
<img src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_right"></ins>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="online-stats">
|
||||||
|
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> —
|
||||||
|
3334 <strong>guests</strong>,
|
||||||
|
8900 <strong>registered</strong>
|
||||||
|
and 76340 <strong>other</strong>
|
||||||
|
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
|
||||||
|
</div>
|
||||||
|
<small>Limit bot activity to periods with less than 10k registered users online.</small>
|
||||||
|
|
||||||
|
<br><br>
|
||||||
|
<strong>© 2005-2026 Frost Dragon Art LLC</strong>
|
||||||
|
|
||||||
|
<div class="footnote">
|
||||||
|
Server Time: May 24, 2026 04:31 AM<br />
|
||||||
|
Page generated in 0.007 seconds<br />[ 26.6% PHP, 73.4% SQL ] (9 queries)<br />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function() {
|
||||||
|
var exists = getCookie('sz');
|
||||||
|
var saved = save_viewport_size();
|
||||||
|
if((!exists && saved) || (exists && saved && exists != saved)) {
|
||||||
|
//window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/prebid10.26.0.js"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/censor.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/prototype.1.7.3.min.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/script.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/color_helper.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
|
||||||
|
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var server_timestamp = 1779622311;
|
||||||
|
var client_timestamp = Date.now() / 1000;
|
||||||
|
var server_timestamp_delta = server_timestamp - client_timestamp;
|
||||||
|
var sfw_cookie_name = 'sfw';
|
||||||
|
var news_cookie_name = 'n';
|
||||||
|
|
||||||
|
//
|
||||||
|
document.addEventListener("DOMContentLoaded", (event) => {
|
||||||
|
//
|
||||||
|
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":40,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":56,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":25,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":53,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":49,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":49,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":28,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":74,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":62,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":59,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":65,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":68,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":65,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":68,"tagSize":[320,50]}},"front_page":{"default":{"tagId":77,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":78,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function() {
|
||||||
|
var ddmenuOptions = {
|
||||||
|
menuId: "ddmenu",
|
||||||
|
linkIdToMenuHtml: null,
|
||||||
|
open: "onmouseover", // or "onclick"
|
||||||
|
delay: 1,
|
||||||
|
speed: 1,
|
||||||
|
keysNav: true,
|
||||||
|
license: "2c1f72"
|
||||||
|
};
|
||||||
|
var ddmenu = new Ddmenu(ddmenuOptions);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<!---
|
||||||
|
|\ /|
|
||||||
|
/_^ ^_\
|
||||||
|
\v/
|
||||||
|
|
||||||
|
The fox goes "moo!"
|
||||||
|
--->
|
||||||
|
|
||||||
|
</html>
|
||||||
455
testdata/html/msg_submissions.html
vendored
Normal file
455
testdata/html/msg_submissions.html
vendored
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="no-js" xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
|
||||||
|
<title>User control panel -- Fur Affinity [dot] net</title>
|
||||||
|
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Fur Affinity | For all things fluff, scaled, and feathered!" />
|
||||||
|
<meta name="keywords" content="fur furry furries fursuit fursuits cosplay brony bronies zootopia scalies kemono anthro anthropormophic art online gallery portfolio" />
|
||||||
|
<meta name="distribution" content="global" />
|
||||||
|
<meta name="copyright" content="Frost Dragon Art LLC" />
|
||||||
|
<meta name="robots" content="noai, noimageai" />
|
||||||
|
|
||||||
|
<link rel="icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
|
||||||
|
<link rel="shortcut icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,500,500i,600,600i,700,700i" rel="stylesheet">
|
||||||
|
<link href="//d.furaffinity.net/media/static/img/icons/bootstrap/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=9; IE=EDGE" />
|
||||||
|
|
||||||
|
<!-- og -->
|
||||||
|
<meta property="og:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
|
||||||
|
<!-- twitter -->
|
||||||
|
<meta name="twitter:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var _faurl = {
|
||||||
|
d: '//d.furaffinity.net',
|
||||||
|
s: '//d.furaffinity.net/media/static',
|
||||||
|
a: '//a.furaffinity.net',
|
||||||
|
r: '//rv.furaffinity.net',
|
||||||
|
t: '//t.furaffinity.net',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/common.js?u=2026050915"></script>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/themes/beta/css/ui_theme_dark.css?u=2026050915" id="css-theme" />
|
||||||
|
|
||||||
|
<!-- browser hints -->
|
||||||
|
<link rel="preconnect" href="//t.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="//a.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="//rv.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="https://www15.smartadserver.com" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/censor.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/prototype.1.7.3.min.js" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/script.js?u=2026050915" as="script" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/color_helper.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915" as="script" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/prebid10.26.0.js" as="script" />
|
||||||
|
|
||||||
|
<!-- Playwire Pre-connect <head> links -->
|
||||||
|
<link rel="preconnect" href="https://cdn.intergient.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://cdn.intergi.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://config.playwire.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://securepubads.g.doubleclick.net" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://z.moatads.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://cdn.playwire.com" crossorigin />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<!-- EU request: yes -->
|
||||||
|
<body class="c-bodyColor"
|
||||||
|
id="pageid-redirect" data-static-path="/themes/beta"
|
||||||
|
>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
0; // attempted fix for fouc in ff
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- sidebar -->
|
||||||
|
<div class="mobile-navigation">
|
||||||
|
|
||||||
|
<div class="mobile-nav-container">
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item left">
|
||||||
|
<label for="mobile-menu-nav" class="css-menu-toggle only-one"><img class="burger-menu" src="/themes/beta/img/fa-burger-menu-icon.png"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item center">
|
||||||
|
<a class="mobile-nav-logo" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item right">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-ac-container">
|
||||||
|
<input id="mobile-menu-nav" name="accordion-1" type="checkbox" />
|
||||||
|
<article class="nav-ac-content mobile-menu">
|
||||||
|
|
||||||
|
<div class="mobile-nav-content-container">
|
||||||
|
|
||||||
|
<div class="aligncenter">
|
||||||
|
<a href="/plus/"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"> FA+</a> |
|
||||||
|
<a href="https://shop.furaffinity.net" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"> Shop</a>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<h2><a href="/browse/">Browse</a></h2>
|
||||||
|
<h2><a href="/search/">Search</a></h2>
|
||||||
|
|
||||||
|
<div class="nav-ac-container">
|
||||||
|
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support ▼</h2></label>
|
||||||
|
<input id="mobile-menu-submenu-0" name="accordion-1" type="checkbox" />
|
||||||
|
<article class="nav-ac-content nav-ac-content-dropdown">
|
||||||
|
<a href="/journals/fender">News & Updates</a><br>
|
||||||
|
<a href="/journals/rednef">Events & Spotlight</a><br>
|
||||||
|
<a href="/help/">Help & Support</a><br>
|
||||||
|
<a href="/advertising.html">Advertising</a><br>
|
||||||
|
<a href="/blm">Black Lives Matter</a><br />
|
||||||
|
<a href="/changelog">Changelog</a><br />
|
||||||
|
<a href="/fight_spam">Internet Safety</a><br />
|
||||||
|
|
||||||
|
<h3>Community Walls</h3>
|
||||||
|
<a href="/route/i_was_here">I Was Here</a><br />
|
||||||
|
<a href="/route/banner_museum">Banner Museum</a><br />
|
||||||
|
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
|
||||||
|
|
||||||
|
<h3>SUPPORT FA</h3>
|
||||||
|
<a href="/plus/">Subscribe to FA+ </a><br>
|
||||||
|
<a href="https://shop.furaffinity.net/" target="_blank">FA Merch Store</a>
|
||||||
|
|
||||||
|
|
||||||
|
<h3>RULES & POLICIES</h3>
|
||||||
|
<a href="/tos">Terms of Service</a><br>
|
||||||
|
<a href="/privacy">Privacy</a><br>
|
||||||
|
<a href="/coc">Code of Conduct</a><br>
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
|
||||||
|
<h3>SUPPORT</h3>
|
||||||
|
<a href="/help/#contact">Contact Us</a><br />
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2><div class="inline hideonmobile hideontablet">
|
||||||
|
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline hideondesktop">
|
||||||
|
<a href="/login">Log In</a><br>
|
||||||
|
<a href="/register">Create an Account</a>
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
|
||||||
|
<h2></h2>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<nav id="ddmenu">
|
||||||
|
<div class="mobile-nav navhideondesktop hideonmobile hideontablet">
|
||||||
|
<div class="mobile-nav-logo">
|
||||||
|
<a class="mobile-nav-logo" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-nav-header-item"><a href="/browse/">Browse</a></div>
|
||||||
|
<div class="mobile-nav-header-item"><a href="/search/">Search</a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-icon"></div>
|
||||||
|
|
||||||
|
<ul class="navhideonmobile">
|
||||||
|
<li class="lileft">
|
||||||
|
<div class="lileft hideonmobile" style="vertical-align:middle;line-height:0 !important" >
|
||||||
|
<a class="top-heading" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="lileft">
|
||||||
|
<a class="top-heading" href="#"><div class="sprite-news menu-space-saver hideonmobile"></div>Support</a>
|
||||||
|
<i class="caret"></i>
|
||||||
|
<div class="dropdown dropdown-left ">
|
||||||
|
<div class="dd-inner">
|
||||||
|
<div class="column">
|
||||||
|
<h3>Community</h3>
|
||||||
|
<a href="/journals/fender">News & Updates</a>
|
||||||
|
<a href="/journals/rednef">Events & Spotlight</a>
|
||||||
|
<a href="/help/">Help & Support</a>
|
||||||
|
<a href="/advertising.html">Advertising</a>
|
||||||
|
<a href="/blm/">Black Lives Matter</a>
|
||||||
|
<a href="/changelog">Changelog</a>
|
||||||
|
<a href="/fight_spam">Internet Safety</a>
|
||||||
|
|
||||||
|
<h3>Community Walls</h3>
|
||||||
|
<a href="/route/i_was_here">I Was Here</a>
|
||||||
|
<a href="/route/banner_museum">Banner Museum</a>
|
||||||
|
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
|
||||||
|
|
||||||
|
<h3>Rules & Policies</h3>
|
||||||
|
<a href="/tos">Terms of Service</a>
|
||||||
|
<a href="/privacy">Privacy</a>
|
||||||
|
<a href="/coc">Code of Conduct</a>
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
|
||||||
|
<h3>Support</h3>
|
||||||
|
<a href="/help/#contact">Contact Us</a>
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="/browse/"><div class="sprite-paw menu-space-saver hideonmobile"></div>Browse</a></li>
|
||||||
|
<li class="lileft"><a class="top-heading hideondesktop" href="/search/">Search</a></li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="/submit/"><div class="sprite-upload menu-space-saver hideonmobile"></div> Upload</a></li>
|
||||||
|
|
||||||
|
|
||||||
|
<li class="lileft"><a class="top-heading" href="/plus/" title="FA+"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"><span class="hidebrowselowres"> FA+</span></a></li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="https://shop.furaffinity.net" title="Shop" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"><span class="hidebrowselowres"> Shop</span></a></li>
|
||||||
|
|
||||||
|
<div class="lileft hideonmobile">
|
||||||
|
<form id="searchbox" method="get" action="/search/">
|
||||||
|
<input type="search" name="q" placeholder="SEARCH">
|
||||||
|
<a href="/search"> </a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="no-sub">
|
||||||
|
<span class="top-heading"><div class="inline hideonmobile hideontablet">
|
||||||
|
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline hideondesktop">
|
||||||
|
<a href="/login">Log In</a><br>
|
||||||
|
<a href="/register">Create an Account</a>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(['init_sfw_button', '.sfw-toggle']);
|
||||||
|
</script>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function(){
|
||||||
|
// all menus that should be opened only one at a time
|
||||||
|
$$('.css-menu-toggle.only-one').invoke('observe', 'click', function(evt) {
|
||||||
|
var curr_input = $(evt.findElement('label').getAttribute('for'));
|
||||||
|
curr_input.next('.nav-ac-content').removeClassName('no-transition');
|
||||||
|
if(curr_input.checked === false) {
|
||||||
|
$$('.css-menu-toggle.only-one').each(function(elm){
|
||||||
|
var elm_input = $(elm.getAttribute('for'));
|
||||||
|
if(elm_input.checked === true) {
|
||||||
|
elm_input.next('.nav-ac-content').addClassName('no-transition');
|
||||||
|
elm_input.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="news-block">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="main-window" class="footer-mobile-tweak g-wrapper">
|
||||||
|
|
||||||
|
<div id="header">
|
||||||
|
<!-- site banner -->
|
||||||
|
<site-banner >
|
||||||
|
<map name="banner-map">
|
||||||
|
<area
|
||||||
|
shape="rect"
|
||||||
|
coords="441,144,1042,197"
|
||||||
|
href="https://link.vgen.co/furaffinity"
|
||||||
|
onclick="javascript: return(confirm(`You've clicked on a usemap link that will redirect you to\n\nhttps://link.vgen.co/furaffinity\n\nAre you fine with being redirected?`))"
|
||||||
|
target="_blank" />
|
||||||
|
</map>
|
||||||
|
<a href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.webp" type="image/webp">
|
||||||
|
<img usemap="#banner-map" src="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.jpg">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</site-banner>
|
||||||
|
|
||||||
|
<a name="top"></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="site-content">
|
||||||
|
<!-- /header -->
|
||||||
|
|
||||||
|
<!-- {redirect} -->
|
||||||
|
<div id="standardpage">
|
||||||
|
|
||||||
|
<section class="aligncenter notice-message user-submitted-links">
|
||||||
|
<div class="section-body alignleft">
|
||||||
|
<h2>System Message</h2>
|
||||||
|
|
||||||
|
<div class="redirect-message">Please log in!</div>
|
||||||
|
|
||||||
|
<div class="proceed-btn-container">
|
||||||
|
<a class="button standard go" href="/login/">Continue »</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- /<div id="site-content"> -->
|
||||||
|
|
||||||
|
<div id="footer">
|
||||||
|
<div class="auto_link footer-links">
|
||||||
|
<a href="/advertising">Advertise</a> |
|
||||||
|
<a href="/plus"><img style="position:relative;top:4px" src="/themes/beta/img/the-golden-pawb.png"> Get FA+</a> |
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a> |
|
||||||
|
<a href="https://shop.furaffinity.net/"><img style="position:relative;top:4px" src="/themes/beta/img/icons/merch_store_icon.png"> Merch Store</a> |
|
||||||
|
<a href="/route/i_was_here">Memorial</a> |
|
||||||
|
<a href="/tos">Terms of Service</a> |
|
||||||
|
<a href="/privacy">Privacy</a> |
|
||||||
|
<a href="/coc">Code of Conduct</a> |
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="footerAds">
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_left"></ins> </div>
|
||||||
|
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<div class="footerAds__slot footerAds__slot--faLogo">
|
||||||
|
<img src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_right"></ins>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="online-stats">
|
||||||
|
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> —
|
||||||
|
3334 <strong>guests</strong>,
|
||||||
|
8900 <strong>registered</strong>
|
||||||
|
and 76340 <strong>other</strong>
|
||||||
|
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
|
||||||
|
</div>
|
||||||
|
<small>Limit bot activity to periods with less than 10k registered users online.</small>
|
||||||
|
|
||||||
|
<br><br>
|
||||||
|
<strong>© 2005-2026 Frost Dragon Art LLC</strong>
|
||||||
|
|
||||||
|
<div class="footnote">
|
||||||
|
Server Time: May 24, 2026 04:31 AM<br />
|
||||||
|
Page generated in 0.007 seconds<br />[ 26.7% PHP, 73.3% SQL ] (9 queries)<br />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function() {
|
||||||
|
var exists = getCookie('sz');
|
||||||
|
var saved = save_viewport_size();
|
||||||
|
if((!exists && saved) || (exists && saved && exists != saved)) {
|
||||||
|
//window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/prebid10.26.0.js"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/censor.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/prototype.1.7.3.min.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/script.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/color_helper.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
|
||||||
|
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var server_timestamp = 1779622309;
|
||||||
|
var client_timestamp = Date.now() / 1000;
|
||||||
|
var server_timestamp_delta = server_timestamp - client_timestamp;
|
||||||
|
var sfw_cookie_name = 'sfw';
|
||||||
|
var news_cookie_name = 'n';
|
||||||
|
|
||||||
|
//
|
||||||
|
document.addEventListener("DOMContentLoaded", (event) => {
|
||||||
|
//
|
||||||
|
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":40,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":56,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":25,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":53,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":49,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":49,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":28,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":74,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":62,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":59,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":65,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":68,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":65,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":68,"tagSize":[320,50]}},"front_page":{"default":{"tagId":77,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":78,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function() {
|
||||||
|
var ddmenuOptions = {
|
||||||
|
menuId: "ddmenu",
|
||||||
|
linkIdToMenuHtml: null,
|
||||||
|
open: "onmouseover", // or "onclick"
|
||||||
|
delay: 1,
|
||||||
|
speed: 1,
|
||||||
|
keysNav: true,
|
||||||
|
license: "2c1f72"
|
||||||
|
};
|
||||||
|
var ddmenu = new Ddmenu(ddmenuOptions);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<!---
|
||||||
|
|\ /|
|
||||||
|
/_^ ^_\
|
||||||
|
\v/
|
||||||
|
|
||||||
|
The fox goes "moo!"
|
||||||
|
--->
|
||||||
|
|
||||||
|
</html>
|
||||||
456
testdata/html/note_view.html
vendored
Normal file
456
testdata/html/note_view.html
vendored
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="no-js" xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
|
||||||
|
<title>User control panel -- Fur Affinity [dot] net</title>
|
||||||
|
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Fur Affinity | For all things fluff, scaled, and feathered!" />
|
||||||
|
<meta name="keywords" content="fur furry furries fursuit fursuits cosplay brony bronies zootopia scalies kemono anthro anthropormophic art online gallery portfolio" />
|
||||||
|
<meta name="distribution" content="global" />
|
||||||
|
<meta name="copyright" content="Frost Dragon Art LLC" />
|
||||||
|
<meta name="robots" content="noai, noimageai" />
|
||||||
|
|
||||||
|
<link rel="icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
|
||||||
|
<link rel="shortcut icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,500,500i,600,600i,700,700i" rel="stylesheet">
|
||||||
|
<link href="//d.furaffinity.net/media/static/img/icons/bootstrap/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=9; IE=EDGE" />
|
||||||
|
|
||||||
|
<!-- og -->
|
||||||
|
<meta property="og:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
|
||||||
|
<!-- twitter -->
|
||||||
|
<meta name="twitter:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var _faurl = {
|
||||||
|
d: '//d.furaffinity.net',
|
||||||
|
s: '//d.furaffinity.net/media/static',
|
||||||
|
a: '//a.furaffinity.net',
|
||||||
|
r: '//rv.furaffinity.net',
|
||||||
|
t: '//t.furaffinity.net',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/common.js?u=2026050915"></script>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/themes/beta/css/ui_theme_dark.css?u=2026050915" id="css-theme" />
|
||||||
|
|
||||||
|
<!-- browser hints -->
|
||||||
|
<link rel="preconnect" href="//t.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="//a.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="//rv.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="https://www15.smartadserver.com" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/censor.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/prototype.1.7.3.min.js" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/script.js?u=2026050915" as="script" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/color_helper.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915" as="script" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/prebid10.26.0.js" as="script" />
|
||||||
|
|
||||||
|
<!-- Playwire Pre-connect <head> links -->
|
||||||
|
<link rel="preconnect" href="https://cdn.intergient.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://cdn.intergi.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://config.playwire.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://securepubads.g.doubleclick.net" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://z.moatads.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://cdn.playwire.com" crossorigin />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<!-- EU request: yes -->
|
||||||
|
<body class="c-bodyColor"
|
||||||
|
id="pageid-redirect" data-static-path="/themes/beta"
|
||||||
|
>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
0; // attempted fix for fouc in ff
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- sidebar -->
|
||||||
|
<div class="mobile-navigation">
|
||||||
|
|
||||||
|
<div class="mobile-nav-container">
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item left">
|
||||||
|
<label for="mobile-menu-nav" class="css-menu-toggle only-one"><img class="burger-menu" src="/themes/beta/img/fa-burger-menu-icon.png"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item center">
|
||||||
|
<a class="mobile-nav-logo" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item right">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-ac-container">
|
||||||
|
<input id="mobile-menu-nav" name="accordion-1" type="checkbox" />
|
||||||
|
<article class="nav-ac-content mobile-menu">
|
||||||
|
|
||||||
|
<div class="mobile-nav-content-container">
|
||||||
|
|
||||||
|
<div class="aligncenter">
|
||||||
|
<a href="/plus/"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"> FA+</a> |
|
||||||
|
<a href="https://shop.furaffinity.net" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"> Shop</a>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<h2><a href="/browse/">Browse</a></h2>
|
||||||
|
<h2><a href="/search/">Search</a></h2>
|
||||||
|
|
||||||
|
<div class="nav-ac-container">
|
||||||
|
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support ▼</h2></label>
|
||||||
|
<input id="mobile-menu-submenu-0" name="accordion-1" type="checkbox" />
|
||||||
|
<article class="nav-ac-content nav-ac-content-dropdown">
|
||||||
|
<a href="/journals/fender">News & Updates</a><br>
|
||||||
|
<a href="/journals/rednef">Events & Spotlight</a><br>
|
||||||
|
<a href="/help/">Help & Support</a><br>
|
||||||
|
<a href="/advertising.html">Advertising</a><br>
|
||||||
|
<a href="/blm">Black Lives Matter</a><br />
|
||||||
|
<a href="/changelog">Changelog</a><br />
|
||||||
|
<a href="/fight_spam">Internet Safety</a><br />
|
||||||
|
|
||||||
|
<h3>Community Walls</h3>
|
||||||
|
<a href="/route/i_was_here">I Was Here</a><br />
|
||||||
|
<a href="/route/banner_museum">Banner Museum</a><br />
|
||||||
|
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
|
||||||
|
|
||||||
|
<h3>SUPPORT FA</h3>
|
||||||
|
<a href="/plus/">Subscribe to FA+ </a><br>
|
||||||
|
<a href="https://shop.furaffinity.net/" target="_blank">FA Merch Store</a>
|
||||||
|
|
||||||
|
|
||||||
|
<h3>RULES & POLICIES</h3>
|
||||||
|
<a href="/tos">Terms of Service</a><br>
|
||||||
|
<a href="/privacy">Privacy</a><br>
|
||||||
|
<a href="/coc">Code of Conduct</a><br>
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
|
||||||
|
<h3>SUPPORT</h3>
|
||||||
|
<a href="/help/#contact">Contact Us</a><br />
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2><div class="inline hideonmobile hideontablet">
|
||||||
|
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline hideondesktop">
|
||||||
|
<a href="/login">Log In</a><br>
|
||||||
|
<a href="/register">Create an Account</a>
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
|
||||||
|
<h2></h2>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<nav id="ddmenu">
|
||||||
|
<div class="mobile-nav navhideondesktop hideonmobile hideontablet">
|
||||||
|
<div class="mobile-nav-logo">
|
||||||
|
<a class="mobile-nav-logo" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-nav-header-item"><a href="/browse/">Browse</a></div>
|
||||||
|
<div class="mobile-nav-header-item"><a href="/search/">Search</a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-icon"></div>
|
||||||
|
|
||||||
|
<ul class="navhideonmobile">
|
||||||
|
<li class="lileft">
|
||||||
|
<div class="lileft hideonmobile" style="vertical-align:middle;line-height:0 !important" >
|
||||||
|
<a class="top-heading" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="lileft">
|
||||||
|
<a class="top-heading" href="#"><div class="sprite-news menu-space-saver hideonmobile"></div>Support</a>
|
||||||
|
<i class="caret"></i>
|
||||||
|
<div class="dropdown dropdown-left ">
|
||||||
|
<div class="dd-inner">
|
||||||
|
<div class="column">
|
||||||
|
<h3>Community</h3>
|
||||||
|
<a href="/journals/fender">News & Updates</a>
|
||||||
|
<a href="/journals/rednef">Events & Spotlight</a>
|
||||||
|
<a href="/help/">Help & Support</a>
|
||||||
|
<a href="/advertising.html">Advertising</a>
|
||||||
|
<a href="/blm/">Black Lives Matter</a>
|
||||||
|
<a href="/changelog">Changelog</a>
|
||||||
|
<a href="/fight_spam">Internet Safety</a>
|
||||||
|
|
||||||
|
<h3>Community Walls</h3>
|
||||||
|
<a href="/route/i_was_here">I Was Here</a>
|
||||||
|
<a href="/route/banner_museum">Banner Museum</a>
|
||||||
|
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
|
||||||
|
|
||||||
|
<h3>Rules & Policies</h3>
|
||||||
|
<a href="/tos">Terms of Service</a>
|
||||||
|
<a href="/privacy">Privacy</a>
|
||||||
|
<a href="/coc">Code of Conduct</a>
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
|
||||||
|
<h3>Support</h3>
|
||||||
|
<a href="/help/#contact">Contact Us</a>
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="/browse/"><div class="sprite-paw menu-space-saver hideonmobile"></div>Browse</a></li>
|
||||||
|
<li class="lileft"><a class="top-heading hideondesktop" href="/search/">Search</a></li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="/submit/"><div class="sprite-upload menu-space-saver hideonmobile"></div> Upload</a></li>
|
||||||
|
|
||||||
|
|
||||||
|
<li class="lileft"><a class="top-heading" href="/plus/" title="FA+"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"><span class="hidebrowselowres"> FA+</span></a></li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="https://shop.furaffinity.net" title="Shop" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"><span class="hidebrowselowres"> Shop</span></a></li>
|
||||||
|
|
||||||
|
<div class="lileft hideonmobile">
|
||||||
|
<form id="searchbox" method="get" action="/search/">
|
||||||
|
<input type="search" name="q" placeholder="SEARCH">
|
||||||
|
<a href="/search"> </a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="no-sub">
|
||||||
|
<span class="top-heading"><div class="inline hideonmobile hideontablet">
|
||||||
|
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline hideondesktop">
|
||||||
|
<a href="/login">Log In</a><br>
|
||||||
|
<a href="/register">Create an Account</a>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(['init_sfw_button', '.sfw-toggle']);
|
||||||
|
</script>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function(){
|
||||||
|
// all menus that should be opened only one at a time
|
||||||
|
$$('.css-menu-toggle.only-one').invoke('observe', 'click', function(evt) {
|
||||||
|
var curr_input = $(evt.findElement('label').getAttribute('for'));
|
||||||
|
curr_input.next('.nav-ac-content').removeClassName('no-transition');
|
||||||
|
if(curr_input.checked === false) {
|
||||||
|
$$('.css-menu-toggle.only-one').each(function(elm){
|
||||||
|
var elm_input = $(elm.getAttribute('for'));
|
||||||
|
if(elm_input.checked === true) {
|
||||||
|
elm_input.next('.nav-ac-content').addClassName('no-transition');
|
||||||
|
elm_input.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="news-block">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="main-window" class="footer-mobile-tweak g-wrapper">
|
||||||
|
|
||||||
|
<div id="header">
|
||||||
|
<!-- site banner -->
|
||||||
|
<site-banner >
|
||||||
|
<map name="banner-map">
|
||||||
|
<area
|
||||||
|
shape="rect"
|
||||||
|
coords="441,144,1042,197"
|
||||||
|
href="https://link.vgen.co/furaffinity"
|
||||||
|
onclick="javascript: return(confirm(`You've clicked on a usemap link that will redirect you to\n\nhttps://link.vgen.co/furaffinity\n\nAre you fine with being redirected?`))"
|
||||||
|
target="_blank" />
|
||||||
|
</map>
|
||||||
|
<a href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.webp" type="image/webp">
|
||||||
|
<img usemap="#banner-map" src="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.jpg">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</site-banner>
|
||||||
|
|
||||||
|
<a name="top"></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="site-content">
|
||||||
|
<!-- /header -->
|
||||||
|
|
||||||
|
<!-- {redirect} -->
|
||||||
|
<div id="standardpage">
|
||||||
|
|
||||||
|
<section class="aligncenter notice-message user-submitted-links">
|
||||||
|
<div class="section-body alignleft">
|
||||||
|
<h2>System Message</h2>
|
||||||
|
|
||||||
|
<div class="redirect-message">Please log in!</div>
|
||||||
|
|
||||||
|
<div class="proceed-btn-container">
|
||||||
|
<a class="button standard go" href="/login/">Continue »</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- /<div id="site-content"> -->
|
||||||
|
|
||||||
|
<div id="footer">
|
||||||
|
<div class="auto_link footer-links">
|
||||||
|
<a href="/advertising">Advertise</a> |
|
||||||
|
<a href="/plus"><img style="position:relative;top:4px" src="/themes/beta/img/the-golden-pawb.png"> Get FA+</a> |
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a> |
|
||||||
|
<a href="https://shop.furaffinity.net/"><img style="position:relative;top:4px" src="/themes/beta/img/icons/merch_store_icon.png"> Merch Store</a> |
|
||||||
|
<a href="/route/i_was_here">Memorial</a> |
|
||||||
|
<a href="/tos">Terms of Service</a> |
|
||||||
|
<a href="/privacy">Privacy</a> |
|
||||||
|
<a href="/coc">Code of Conduct</a> |
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="footerAds">
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_left"></ins> </div>
|
||||||
|
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<div class="footerAds__slot footerAds__slot--faLogo">
|
||||||
|
<img src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<ins class="footerAds__slot format--faSmallRectangle jsAdSlot hidden" data-id="footer_right_top"></ins>
|
||||||
|
<ins class="footerAds__slot format--faSmallRectangle jsAdSlot hidden" data-id="footer_right_bottom"></ins>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="online-stats">
|
||||||
|
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> —
|
||||||
|
3334 <strong>guests</strong>,
|
||||||
|
8900 <strong>registered</strong>
|
||||||
|
and 76340 <strong>other</strong>
|
||||||
|
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
|
||||||
|
</div>
|
||||||
|
<small>Limit bot activity to periods with less than 10k registered users online.</small>
|
||||||
|
|
||||||
|
<br><br>
|
||||||
|
<strong>© 2005-2026 Frost Dragon Art LLC</strong>
|
||||||
|
|
||||||
|
<div class="footnote">
|
||||||
|
Server Time: May 24, 2026 04:31 AM<br />
|
||||||
|
Page generated in 0.009 seconds<br />[ 26.5% PHP, 73.5% SQL ] (9 queries)<br />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function() {
|
||||||
|
var exists = getCookie('sz');
|
||||||
|
var saved = save_viewport_size();
|
||||||
|
if((!exists && saved) || (exists && saved && exists != saved)) {
|
||||||
|
//window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/prebid10.26.0.js"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/censor.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/prototype.1.7.3.min.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/script.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/color_helper.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
|
||||||
|
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var server_timestamp = 1779622312;
|
||||||
|
var client_timestamp = Date.now() / 1000;
|
||||||
|
var server_timestamp_delta = server_timestamp - client_timestamp;
|
||||||
|
var sfw_cookie_name = 'sfw';
|
||||||
|
var news_cookie_name = 'n';
|
||||||
|
|
||||||
|
//
|
||||||
|
document.addEventListener("DOMContentLoaded", (event) => {
|
||||||
|
//
|
||||||
|
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":40,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":56,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":25,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":53,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":49,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":49,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":28,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":74,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":62,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":59,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":65,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":68,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":65,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":68,"tagSize":[320,50]}},"front_page":{"default":{"tagId":77,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":78,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function() {
|
||||||
|
var ddmenuOptions = {
|
||||||
|
menuId: "ddmenu",
|
||||||
|
linkIdToMenuHtml: null,
|
||||||
|
open: "onmouseover", // or "onclick"
|
||||||
|
delay: 1,
|
||||||
|
speed: 1,
|
||||||
|
keysNav: true,
|
||||||
|
license: "2c1f72"
|
||||||
|
};
|
||||||
|
var ddmenu = new Ddmenu(ddmenuOptions);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<!---
|
||||||
|
|\ /|
|
||||||
|
/_^ ^_\
|
||||||
|
\v/
|
||||||
|
|
||||||
|
The fox goes "moo!"
|
||||||
|
--->
|
||||||
|
|
||||||
|
</html>
|
||||||
669
testdata/html/scraps_page1.html
vendored
Normal file
669
testdata/html/scraps_page1.html
vendored
Normal file
@@ -0,0 +1,669 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="no-js" xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
|
||||||
|
<title>Scraps Gallery for KazuCreations -- Fur Affinity [dot] net</title>
|
||||||
|
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Fur Affinity | For all things fluff, scaled, and feathered!" />
|
||||||
|
<meta name="keywords" content="fur furry furries fursuit fursuits cosplay brony bronies zootopia scalies kemono anthro anthropormophic art online gallery portfolio" />
|
||||||
|
<meta name="distribution" content="global" />
|
||||||
|
<meta name="copyright" content="Frost Dragon Art LLC" />
|
||||||
|
<meta name="robots" content="noai, noimageai" />
|
||||||
|
|
||||||
|
<link rel="icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
|
||||||
|
<link rel="shortcut icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,500,500i,600,600i,700,700i" rel="stylesheet">
|
||||||
|
<link href="//d.furaffinity.net/media/static/img/icons/bootstrap/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=9; IE=EDGE" />
|
||||||
|
|
||||||
|
<!-- og -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content="Scraps Gallery for KazuCreations -- Fur Affinity [dot] net" />
|
||||||
|
<meta property="og:url" content="https://www.furaffinity.net/gallery/kazucreations/" />
|
||||||
|
<meta property="og:description" content="moved to featherworks.studio" />
|
||||||
|
<meta property="og:image" content="https://t.furaffinity.net/30549394@600-1550693644.jpg" />
|
||||||
|
<meta property="og:image:secure_url" content="https://t.furaffinity.net/30549394@600-1550693644.jpg" />
|
||||||
|
<meta property="og:image:type" content="image/jpeg" />
|
||||||
|
<meta property="og:image:width" content="423" />
|
||||||
|
<meta property="og:image:height" content="600" />
|
||||||
|
<!-- twitter -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:domain" content="furaffinity.net" />
|
||||||
|
<meta name="twitter:site" content="@furaffinity" />
|
||||||
|
<meta name="twitter:title" content="Scraps Gallery for KazuCreations -- Fur Affinity [dot] net" />
|
||||||
|
<meta name="twitter:description" content="moved to featherworks.studio" />
|
||||||
|
<meta name="twitter:url" content="https://www.furaffinity.net/gallery/kazucreations/" />
|
||||||
|
<meta name="twitter:image" content="https://t.furaffinity.net/30549394@600-1550693644.jpg" />
|
||||||
|
<meta name="twitter:label1" content="Submission Title" />
|
||||||
|
<meta name="twitter:data1" content="[NDS] #1 Octopus" />
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var _faurl = {
|
||||||
|
d: '//d.furaffinity.net',
|
||||||
|
s: '//d.furaffinity.net/media/static',
|
||||||
|
a: '//a.furaffinity.net',
|
||||||
|
r: '//rv.furaffinity.net',
|
||||||
|
t: '//t.furaffinity.net',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/common.js?u=2026050915"></script>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/themes/beta/css/ui_theme_dark.css?u=2026050915" id="css-theme" />
|
||||||
|
|
||||||
|
<!-- browser hints -->
|
||||||
|
<link rel="preconnect" href="//t.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="//a.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="//rv.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="https://www15.smartadserver.com" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/censor.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/prototype.1.7.3.min.js" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/script.js?u=2026050915" as="script" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/color_helper.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915" as="script" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/prebid10.26.0.js" as="script" />
|
||||||
|
|
||||||
|
<!-- Playwire Pre-connect <head> links -->
|
||||||
|
<link rel="preconnect" href="https://cdn.intergient.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://cdn.intergi.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://config.playwire.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://securepubads.g.doubleclick.net" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://z.moatads.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://cdn.playwire.com" crossorigin />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<!-- EU request: yes -->
|
||||||
|
<body class="c-bodyColor"
|
||||||
|
id="pageid-gallery" data-static-path="/themes/beta"
|
||||||
|
>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
0; // attempted fix for fouc in ff
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- sidebar -->
|
||||||
|
<div class="mobile-navigation">
|
||||||
|
|
||||||
|
<div class="mobile-nav-container">
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item left">
|
||||||
|
<label for="mobile-menu-nav" class="css-menu-toggle only-one"><img class="burger-menu" src="/themes/beta/img/fa-burger-menu-icon.png"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item center">
|
||||||
|
<a class="mobile-nav-logo" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item right">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-ac-container">
|
||||||
|
<input id="mobile-menu-nav" name="accordion-1" type="checkbox" />
|
||||||
|
<article class="nav-ac-content mobile-menu">
|
||||||
|
|
||||||
|
<div class="mobile-nav-content-container">
|
||||||
|
|
||||||
|
<div class="aligncenter">
|
||||||
|
<a href="/plus/"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"> FA+</a> |
|
||||||
|
<a href="https://shop.furaffinity.net" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"> Shop</a>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<h2><a href="/browse/">Browse</a></h2>
|
||||||
|
<h2><a href="/search/">Search</a></h2>
|
||||||
|
|
||||||
|
<div class="nav-ac-container">
|
||||||
|
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support ▼</h2></label>
|
||||||
|
<input id="mobile-menu-submenu-0" name="accordion-1" type="checkbox" />
|
||||||
|
<article class="nav-ac-content nav-ac-content-dropdown">
|
||||||
|
<a href="/journals/fender">News & Updates</a><br>
|
||||||
|
<a href="/journals/rednef">Events & Spotlight</a><br>
|
||||||
|
<a href="/help/">Help & Support</a><br>
|
||||||
|
<a href="/advertising.html">Advertising</a><br>
|
||||||
|
<a href="/blm">Black Lives Matter</a><br />
|
||||||
|
<a href="/changelog">Changelog</a><br />
|
||||||
|
<a href="/fight_spam">Internet Safety</a><br />
|
||||||
|
|
||||||
|
<h3>Community Walls</h3>
|
||||||
|
<a href="/route/i_was_here">I Was Here</a><br />
|
||||||
|
<a href="/route/banner_museum">Banner Museum</a><br />
|
||||||
|
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
|
||||||
|
|
||||||
|
<h3>SUPPORT FA</h3>
|
||||||
|
<a href="/plus/">Subscribe to FA+ </a><br>
|
||||||
|
<a href="https://shop.furaffinity.net/" target="_blank">FA Merch Store</a>
|
||||||
|
|
||||||
|
|
||||||
|
<h3>RULES & POLICIES</h3>
|
||||||
|
<a href="/tos">Terms of Service</a><br>
|
||||||
|
<a href="/privacy">Privacy</a><br>
|
||||||
|
<a href="/coc">Code of Conduct</a><br>
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
|
||||||
|
<h3>SUPPORT</h3>
|
||||||
|
<a href="/help/#contact">Contact Us</a><br />
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2><div class="inline hideonmobile hideontablet">
|
||||||
|
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline hideondesktop">
|
||||||
|
<a href="/login">Log In</a><br>
|
||||||
|
<a href="/register">Create an Account</a>
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
|
||||||
|
<h2></h2>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<nav id="ddmenu">
|
||||||
|
<div class="mobile-nav navhideondesktop hideonmobile hideontablet">
|
||||||
|
<div class="mobile-nav-logo">
|
||||||
|
<a class="mobile-nav-logo" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-nav-header-item"><a href="/browse/">Browse</a></div>
|
||||||
|
<div class="mobile-nav-header-item"><a href="/search/">Search</a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-icon"></div>
|
||||||
|
|
||||||
|
<ul class="navhideonmobile">
|
||||||
|
<li class="lileft">
|
||||||
|
<div class="lileft hideonmobile" style="vertical-align:middle;line-height:0 !important" >
|
||||||
|
<a class="top-heading" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="lileft">
|
||||||
|
<a class="top-heading" href="#"><div class="sprite-news menu-space-saver hideonmobile"></div>Support</a>
|
||||||
|
<i class="caret"></i>
|
||||||
|
<div class="dropdown dropdown-left ">
|
||||||
|
<div class="dd-inner">
|
||||||
|
<div class="column">
|
||||||
|
<h3>Community</h3>
|
||||||
|
<a href="/journals/fender">News & Updates</a>
|
||||||
|
<a href="/journals/rednef">Events & Spotlight</a>
|
||||||
|
<a href="/help/">Help & Support</a>
|
||||||
|
<a href="/advertising.html">Advertising</a>
|
||||||
|
<a href="/blm/">Black Lives Matter</a>
|
||||||
|
<a href="/changelog">Changelog</a>
|
||||||
|
<a href="/fight_spam">Internet Safety</a>
|
||||||
|
|
||||||
|
<h3>Community Walls</h3>
|
||||||
|
<a href="/route/i_was_here">I Was Here</a>
|
||||||
|
<a href="/route/banner_museum">Banner Museum</a>
|
||||||
|
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
|
||||||
|
|
||||||
|
<h3>Rules & Policies</h3>
|
||||||
|
<a href="/tos">Terms of Service</a>
|
||||||
|
<a href="/privacy">Privacy</a>
|
||||||
|
<a href="/coc">Code of Conduct</a>
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
|
||||||
|
<h3>Support</h3>
|
||||||
|
<a href="/help/#contact">Contact Us</a>
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="/browse/"><div class="sprite-paw menu-space-saver hideonmobile"></div>Browse</a></li>
|
||||||
|
<li class="lileft"><a class="top-heading hideondesktop" href="/search/">Search</a></li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="/submit/"><div class="sprite-upload menu-space-saver hideonmobile"></div> Upload</a></li>
|
||||||
|
|
||||||
|
|
||||||
|
<li class="lileft"><a class="top-heading" href="/plus/" title="FA+"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"><span class="hidebrowselowres"> FA+</span></a></li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="https://shop.furaffinity.net" title="Shop" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"><span class="hidebrowselowres"> Shop</span></a></li>
|
||||||
|
|
||||||
|
<div class="lileft hideonmobile">
|
||||||
|
<form id="searchbox" method="get" action="/search/">
|
||||||
|
<input type="search" name="q" placeholder="SEARCH">
|
||||||
|
<a href="/search"> </a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="no-sub">
|
||||||
|
<span class="top-heading"><div class="inline hideonmobile hideontablet">
|
||||||
|
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline hideondesktop">
|
||||||
|
<a href="/login">Log In</a><br>
|
||||||
|
<a href="/register">Create an Account</a>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(['init_sfw_button', '.sfw-toggle']);
|
||||||
|
</script>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function(){
|
||||||
|
// all menus that should be opened only one at a time
|
||||||
|
$$('.css-menu-toggle.only-one').invoke('observe', 'click', function(evt) {
|
||||||
|
var curr_input = $(evt.findElement('label').getAttribute('for'));
|
||||||
|
curr_input.next('.nav-ac-content').removeClassName('no-transition');
|
||||||
|
if(curr_input.checked === false) {
|
||||||
|
$$('.css-menu-toggle.only-one').each(function(elm){
|
||||||
|
var elm_input = $(elm.getAttribute('for'));
|
||||||
|
if(elm_input.checked === true) {
|
||||||
|
elm_input.next('.nav-ac-content').addClassName('no-transition');
|
||||||
|
elm_input.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="news-block">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="main-window" class="footer-mobile-tweak g-wrapper">
|
||||||
|
|
||||||
|
<div id="header">
|
||||||
|
<!-- user profile banner -->
|
||||||
|
|
||||||
|
<site-banner>
|
||||||
|
<a href="/">
|
||||||
|
<picture>
|
||||||
|
<source media="(max-width: 799px)" srcset="//d.furaffinity.net/art/kazucreations/1770312990/profile_banner_mobile.jpg">
|
||||||
|
<source media="(min-width: 800px)" srcset="//d.furaffinity.net/art/kazucreations/1770312990/profile_banner.jpg">
|
||||||
|
<img src="//d.furaffinity.net/art/kazucreations/1770312990/profile_banner.jpg" alt="Profile Banner image">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</site-banner>
|
||||||
|
|
||||||
|
<a name="top"></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="site-content">
|
||||||
|
<!-- /header -->
|
||||||
|
|
||||||
|
|
||||||
|
<userpage-nav-header>
|
||||||
|
|
||||||
|
<userpage-nav-avatar>
|
||||||
|
<a class="current" href="/user/kazucreations/"><img alt="kazucreations" src="//a.furaffinity.net/1770312952/kazucreations.gif"/></a>
|
||||||
|
</userpage-nav-avatar>
|
||||||
|
|
||||||
|
|
||||||
|
<userpage-nav-user-details>
|
||||||
|
<div class="top-bar">
|
||||||
|
<username>
|
||||||
|
<div class="c-usernameBlock username-in-nav-bar">
|
||||||
|
|
||||||
|
<a class="c-usernameBlock__displayName js-displayName-block" href="/user/kazucreations/">
|
||||||
|
<span class="js-displayName">KazuCreations</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="c-usernameBlock__userName js-userName-block" href="/user/kazucreations/">
|
||||||
|
<span><span class="c-usernameBlock__symbol" title="Member" alt="Member">~</span>kazucreations</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</username>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="font-small">
|
||||||
|
<span class="user-title">
|
||||||
|
Fursuit Maker | <span class="hideonmobile">Registered:</span> <span class="popup_date" data-title-date="0" data-24-hour="0" data-time="1443468107" title="10 years ago" disabled>September 28, 2015 03:21:47 PM</span> </span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<userpage-nav-links>
|
||||||
|
<ul class="user-nav-page-links">
|
||||||
|
<li><h3><a href="/user/kazucreations/">Home</a></h3></li>
|
||||||
|
<li><h3><a href="/gallery/kazucreations/">Gallery</a></h3></li>
|
||||||
|
<li><h3><a class="current" href="/scraps/kazucreations/">Scraps</a></h3></li>
|
||||||
|
<li><h3><a href="/favorites/kazucreations/">Favs</a></h3></li>
|
||||||
|
<li><h3><a href="/journals/kazucreations/">Journals</a></h3></li>
|
||||||
|
|
||||||
|
<li><h3><a href="/commissions/kazucreations/">Commissions</a></h3></li>
|
||||||
|
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</userpage-nav-links>
|
||||||
|
</userpage-nav-user-details>
|
||||||
|
|
||||||
|
|
||||||
|
<userpage-nav-interface-buttons>
|
||||||
|
<a class="button standard samewidth go" style="text-transform: capitalize;" id="watch-button" href="/watch/kazucreations/?key=">Watch</a>
|
||||||
|
<a class="button standard" href="/newpm/kazucreations/"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="transform: ;msFilter:;"><path d="M20 4H4c-1.103 0-2 .897-2 2v12c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2zm0 2v.511l-8 6.223-8-6.222V6h16zM4 18V9.044l7.386 5.745a.994.994 0 0 0 1.228 0L20 9.044 20.002 18H4z"></path></svg></a>
|
||||||
|
|
||||||
|
</userpage-nav-interface-buttons>
|
||||||
|
|
||||||
|
<script src="/themes/beta/js/widgets/user-nav-block-watch.js?u=2026050915"></script>
|
||||||
|
</userpage-nav-header>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="clear"></div>
|
||||||
|
<!--- /USER NAV --->
|
||||||
|
|
||||||
|
<div id="page-galleryscraps">
|
||||||
|
<div id="columnpage">
|
||||||
|
<div class="sidebar">
|
||||||
|
|
||||||
|
<div class="folder-list">
|
||||||
|
<div class="user-folders">
|
||||||
|
<div class="container-item-top">
|
||||||
|
<h4>Gallery Folders </h4>
|
||||||
|
</div>
|
||||||
|
<div class="default-folders">
|
||||||
|
<ul style="list-style-type:none">
|
||||||
|
<li>
|
||||||
|
<a href="/gallery/kazucreations/" class="dotted">Main Gallery</a>
|
||||||
|
</li>
|
||||||
|
<li class="active">
|
||||||
|
❯❯ <strong>Scraps</strong>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="default-group" style="list-style-type:none">
|
||||||
|
<li>
|
||||||
|
<a href="/gallery/kazucreations/folder/88225/Headshots" title="25 submissions" class="dotted">Headshots</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/gallery/kazucreations/folder/89257/Badges" title="23 submissions" class="dotted">Badges</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/gallery/kazucreations/folder/104407/Multiple-Characters" title="9 submissions" class="dotted">Multiple Characters</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/gallery/kazucreations/folder/176479/Digital-Art" title="55 submissions" class="dotted">Digital Art</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/gallery/kazucreations/folder/191089/Fullbody" title="21 submissions" class="dotted">Fullbody</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/gallery/kazucreations/folder/196612/Photos" title="6 submissions" class="dotted">Photos</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/gallery/kazucreations/folder/294235/My-Suits" title="6 submissions" class="dotted">My Suits</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/gallery/kazucreations/folder/312886/Traditional-Art" title="23 submissions" class="dotted">Traditional Art</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/gallery/kazucreations/folder/442425/Videos" title="2 submissions" class="dotted">Videos</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/gallery/kazucreations/folder/534625/Plushies" title="6 submissions" class="dotted">Plushies</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/gallery/kazucreations/folder/537118/Clothing" title="1 submissions" class="dotted">Clothing</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/gallery/kazucreations/folder/538750/Fursuit" title="8 submissions" class="dotted">Fursuit</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/gallery/kazucreations/folder/559610/Inktober" title="13 submissions" class="dotted">Inktober</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/gallery/kazucreations/folder/620685/NDS" title="1 submissions" class="dotted">NDS</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="rectangleAd">
|
||||||
|
<ins data-id="sidebar" class="rectangleAd__slot format--mediumRectangle jsAdSlot"></ins>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="leaderboardAd">
|
||||||
|
<ins data-id="header_middle" class="leaderboardAd__slot format--leaderboard jsAdSlot hidden"></ins>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="gallery-section">
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="submission-list">
|
||||||
|
|
||||||
|
<div class="gallery-navigation aligncenter">
|
||||||
|
<div class="inline" style="width:32%">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="navigation-page-name inline" style="width:32%;">
|
||||||
|
Page #1 </div>
|
||||||
|
|
||||||
|
<div class="inline" style="width:32%">
|
||||||
|
|
||||||
|
<!--button class="button standard" type="button">Next</button-->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section id="gallery-gallery" class="gallery no-padding aligncenter no-artistname s-200 ">
|
||||||
|
<figure id="sid-30549394" class="r-general t-image">
|
||||||
|
<b>
|
||||||
|
<u>
|
||||||
|
<a href="/view/30549394/">
|
||||||
|
<noscript><span class="is-noScriptEnabled"></span></noscript>
|
||||||
|
|
||||||
|
<img class="blocked-content" data-tags="u_kazucreations c_artwork_digital t_all s_aquatic_other octopus sketch" alt="" src="//t.furaffinity.net/30549394@200-1550693644.jpg" data-width="141.406" data-height="200" style="width:141.406px; height:200px" loading="lazy" decoding="async" />
|
||||||
|
<i title="Click for description"></i>
|
||||||
|
</a>
|
||||||
|
</u>
|
||||||
|
</b>
|
||||||
|
<figcaption>
|
||||||
|
<p>
|
||||||
|
<a href="/view/30549394/" title="[NDS] #1 Octopus">[NDS] #1 Octopus</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<i>by</i> <a href="/user/kazucreations/" title="KazuCreations">KazuCreations</a>
|
||||||
|
</p>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(['init_gallery', 'gallery-gallery']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="gallery-navigation aligncenter">
|
||||||
|
<div class="inline" style="width:32%">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline" style="width:32%">
|
||||||
|
<div class="mobile-button">
|
||||||
|
<button type="button" class="button standard toggle_titles">Disable Titles</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline" style="width:32%">
|
||||||
|
|
||||||
|
<!--button class="button standard" type="button">Next</button-->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<script id="js-submissionData" type="application/json">{"30549394":{"title":"[NDS] #1 Octopus","description":"asked a classmate what to draw and she said octopus,so yeah, have some xD","username":"KazuCreations","lower":"kazucreations"}}</script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
var descriptions = JSON.parse(document.getElementById('js-submissionData').textContent);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- /<div id="site-content"> -->
|
||||||
|
|
||||||
|
<div id="footer">
|
||||||
|
<div class="auto_link footer-links">
|
||||||
|
<a href="/advertising">Advertise</a> |
|
||||||
|
<a href="/plus"><img style="position:relative;top:4px" src="/themes/beta/img/the-golden-pawb.png"> Get FA+</a> |
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a> |
|
||||||
|
<a href="https://shop.furaffinity.net/"><img style="position:relative;top:4px" src="/themes/beta/img/icons/merch_store_icon.png"> Merch Store</a> |
|
||||||
|
<a href="/route/i_was_here">Memorial</a> |
|
||||||
|
<a href="/tos">Terms of Service</a> |
|
||||||
|
<a href="/privacy">Privacy</a> |
|
||||||
|
<a href="/coc">Code of Conduct</a> |
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="footerAds">
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_left"></ins> </div>
|
||||||
|
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<div class="footerAds__slot footerAds__slot--faLogo">
|
||||||
|
<img src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<ins class="footerAds__slot format--faSmallRectangle jsAdSlot hidden" data-id="footer_right_top"></ins>
|
||||||
|
<ins class="footerAds__slot format--faSmallRectangle jsAdSlot hidden" data-id="footer_right_bottom"></ins>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="online-stats">
|
||||||
|
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> —
|
||||||
|
3334 <strong>guests</strong>,
|
||||||
|
8900 <strong>registered</strong>
|
||||||
|
and 76340 <strong>other</strong>
|
||||||
|
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
|
||||||
|
</div>
|
||||||
|
<small>Limit bot activity to periods with less than 10k registered users online.</small>
|
||||||
|
|
||||||
|
<br><br>
|
||||||
|
<strong>© 2005-2026 Frost Dragon Art LLC</strong>
|
||||||
|
|
||||||
|
<div class="footnote">
|
||||||
|
Server Time: May 24, 2026 04:31 AM<br />
|
||||||
|
Page generated in 0.015 seconds<br />[ 38.7% PHP, 61.3% SQL ] (21 queries)<br />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function() {
|
||||||
|
var exists = getCookie('sz');
|
||||||
|
var saved = save_viewport_size();
|
||||||
|
if((!exists && saved) || (exists && saved && exists != saved)) {
|
||||||
|
//window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/prebid10.26.0.js"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/censor.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/prototype.1.7.3.min.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/script.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/color_helper.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
|
||||||
|
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var server_timestamp = 1779622302;
|
||||||
|
var client_timestamp = Date.now() / 1000;
|
||||||
|
var server_timestamp_delta = server_timestamp - client_timestamp;
|
||||||
|
var sfw_cookie_name = 'sfw';
|
||||||
|
var news_cookie_name = 'n';
|
||||||
|
|
||||||
|
//
|
||||||
|
document.addEventListener("DOMContentLoaded", (event) => {
|
||||||
|
//
|
||||||
|
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":40,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":56,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":25,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":53,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":49,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":49,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":28,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":74,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":62,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":59,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":65,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":68,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":65,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":68,"tagSize":[320,50]}},"front_page":{"default":{"tagId":77,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":78,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function() {
|
||||||
|
var ddmenuOptions = {
|
||||||
|
menuId: "ddmenu",
|
||||||
|
linkIdToMenuHtml: null,
|
||||||
|
open: "onmouseover", // or "onclick"
|
||||||
|
delay: 1,
|
||||||
|
speed: 1,
|
||||||
|
keysNav: true,
|
||||||
|
license: "2c1f72"
|
||||||
|
};
|
||||||
|
var ddmenu = new Ddmenu(ddmenuOptions);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<!---
|
||||||
|
|\ /|
|
||||||
|
/_^ ^_\
|
||||||
|
\v/
|
||||||
|
|
||||||
|
The fox goes "moo!"
|
||||||
|
--->
|
||||||
|
|
||||||
|
</html>
|
||||||
2212
testdata/html/search_results.html
vendored
Normal file
2212
testdata/html/search_results.html
vendored
Normal file
File diff suppressed because one or more lines are too long
1048
testdata/html/submission.html
vendored
Normal file
1048
testdata/html/submission.html
vendored
Normal file
File diff suppressed because one or more lines are too long
1008
testdata/html/submission_story.html
vendored
Normal file
1008
testdata/html/submission_story.html
vendored
Normal file
File diff suppressed because one or more lines are too long
25
testdata/html/system_message_not_found.html
vendored
Normal file
25
testdata/html/system_message_not_found.html
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
||||||
|
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<!-- -->
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>System Error</title>
|
||||||
|
|
||||||
|
<link href="/themes/beta/img/favicon.ico"/>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/themes/beta/css/ui_theme_dark.css?u=2026050915" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="c-bodyColor">
|
||||||
|
|
||||||
|
<section style="margin: 30px auto; max-width: 800px;">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>System Error</h2>
|
||||||
|
</div>
|
||||||
|
<div class="section-body">
|
||||||
|
The submission you are trying to find is not in our database. <br>
|
||||||
|
<div class="alignright"><a class="button standard" href="javascript:history.go(-1)">Click here to go back</a></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
458
testdata/html/user.html
vendored
Normal file
458
testdata/html/user.html
vendored
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="no-js" xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
|
||||||
|
<title>Userpage of SoXX-TheFennec -- Fur Affinity [dot] net</title>
|
||||||
|
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Fur Affinity | For all things fluff, scaled, and feathered!" />
|
||||||
|
<meta name="keywords" content="fur furry furries fursuit fursuits cosplay brony bronies zootopia scalies kemono anthro anthropormophic art online gallery portfolio" />
|
||||||
|
<meta name="distribution" content="global" />
|
||||||
|
<meta name="copyright" content="Frost Dragon Art LLC" />
|
||||||
|
<meta name="robots" content="noai, noimageai" />
|
||||||
|
|
||||||
|
<link rel="icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
|
||||||
|
<link rel="shortcut icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,500,500i,600,600i,700,700i" rel="stylesheet">
|
||||||
|
<link href="//d.furaffinity.net/media/static/img/icons/bootstrap/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=9; IE=EDGE" />
|
||||||
|
|
||||||
|
<!-- generic -->
|
||||||
|
<meta name="robots" content="noindex" />
|
||||||
|
<!-- og -->
|
||||||
|
<meta property="og:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
|
||||||
|
<!-- twitter -->
|
||||||
|
<meta name="twitter:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var _faurl = {
|
||||||
|
d: '//d.furaffinity.net',
|
||||||
|
s: '//d.furaffinity.net/media/static',
|
||||||
|
a: '//a.furaffinity.net',
|
||||||
|
r: '//rv.furaffinity.net',
|
||||||
|
t: '//t.furaffinity.net',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/common.js?u=2026050915"></script>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/themes/beta/css/ui_theme_dark.css?u=2026050915" id="css-theme" />
|
||||||
|
|
||||||
|
<!-- browser hints -->
|
||||||
|
<link rel="preconnect" href="//t.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="//a.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="//rv.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="https://www15.smartadserver.com" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/censor.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/prototype.1.7.3.min.js" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/script.js?u=2026050915" as="script" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/color_helper.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915" as="script" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/prebid10.26.0.js" as="script" />
|
||||||
|
|
||||||
|
<!-- Playwire Pre-connect <head> links -->
|
||||||
|
<link rel="preconnect" href="https://cdn.intergient.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://cdn.intergi.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://config.playwire.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://securepubads.g.doubleclick.net" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://z.moatads.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://cdn.playwire.com" crossorigin />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<!-- EU request: yes -->
|
||||||
|
<body class="c-bodyColor"
|
||||||
|
id="pageid-redirect" data-static-path="/themes/beta"
|
||||||
|
>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
0; // attempted fix for fouc in ff
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- sidebar -->
|
||||||
|
<div class="mobile-navigation">
|
||||||
|
|
||||||
|
<div class="mobile-nav-container">
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item left">
|
||||||
|
<label for="mobile-menu-nav" class="css-menu-toggle only-one"><img class="burger-menu" src="/themes/beta/img/fa-burger-menu-icon.png"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item center">
|
||||||
|
<a class="mobile-nav-logo" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item right">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-ac-container">
|
||||||
|
<input id="mobile-menu-nav" name="accordion-1" type="checkbox" />
|
||||||
|
<article class="nav-ac-content mobile-menu">
|
||||||
|
|
||||||
|
<div class="mobile-nav-content-container">
|
||||||
|
|
||||||
|
<div class="aligncenter">
|
||||||
|
<a href="/plus/"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"> FA+</a> |
|
||||||
|
<a href="https://shop.furaffinity.net" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"> Shop</a>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<h2><a href="/browse/">Browse</a></h2>
|
||||||
|
<h2><a href="/search/">Search</a></h2>
|
||||||
|
|
||||||
|
<div class="nav-ac-container">
|
||||||
|
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support ▼</h2></label>
|
||||||
|
<input id="mobile-menu-submenu-0" name="accordion-1" type="checkbox" />
|
||||||
|
<article class="nav-ac-content nav-ac-content-dropdown">
|
||||||
|
<a href="/journals/fender">News & Updates</a><br>
|
||||||
|
<a href="/journals/rednef">Events & Spotlight</a><br>
|
||||||
|
<a href="/help/">Help & Support</a><br>
|
||||||
|
<a href="/advertising.html">Advertising</a><br>
|
||||||
|
<a href="/blm">Black Lives Matter</a><br />
|
||||||
|
<a href="/changelog">Changelog</a><br />
|
||||||
|
<a href="/fight_spam">Internet Safety</a><br />
|
||||||
|
|
||||||
|
<h3>Community Walls</h3>
|
||||||
|
<a href="/route/i_was_here">I Was Here</a><br />
|
||||||
|
<a href="/route/banner_museum">Banner Museum</a><br />
|
||||||
|
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
|
||||||
|
|
||||||
|
<h3>SUPPORT FA</h3>
|
||||||
|
<a href="/plus/">Subscribe to FA+ </a><br>
|
||||||
|
<a href="https://shop.furaffinity.net/" target="_blank">FA Merch Store</a>
|
||||||
|
|
||||||
|
|
||||||
|
<h3>RULES & POLICIES</h3>
|
||||||
|
<a href="/tos">Terms of Service</a><br>
|
||||||
|
<a href="/privacy">Privacy</a><br>
|
||||||
|
<a href="/coc">Code of Conduct</a><br>
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
|
||||||
|
<h3>SUPPORT</h3>
|
||||||
|
<a href="/help/#contact">Contact Us</a><br />
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2><div class="inline hideonmobile hideontablet">
|
||||||
|
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline hideondesktop">
|
||||||
|
<a href="/login">Log In</a><br>
|
||||||
|
<a href="/register">Create an Account</a>
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
|
||||||
|
<h2></h2>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<nav id="ddmenu">
|
||||||
|
<div class="mobile-nav navhideondesktop hideonmobile hideontablet">
|
||||||
|
<div class="mobile-nav-logo">
|
||||||
|
<a class="mobile-nav-logo" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-nav-header-item"><a href="/browse/">Browse</a></div>
|
||||||
|
<div class="mobile-nav-header-item"><a href="/search/">Search</a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-icon"></div>
|
||||||
|
|
||||||
|
<ul class="navhideonmobile">
|
||||||
|
<li class="lileft">
|
||||||
|
<div class="lileft hideonmobile" style="vertical-align:middle;line-height:0 !important" >
|
||||||
|
<a class="top-heading" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="lileft">
|
||||||
|
<a class="top-heading" href="#"><div class="sprite-news menu-space-saver hideonmobile"></div>Support</a>
|
||||||
|
<i class="caret"></i>
|
||||||
|
<div class="dropdown dropdown-left ">
|
||||||
|
<div class="dd-inner">
|
||||||
|
<div class="column">
|
||||||
|
<h3>Community</h3>
|
||||||
|
<a href="/journals/fender">News & Updates</a>
|
||||||
|
<a href="/journals/rednef">Events & Spotlight</a>
|
||||||
|
<a href="/help/">Help & Support</a>
|
||||||
|
<a href="/advertising.html">Advertising</a>
|
||||||
|
<a href="/blm/">Black Lives Matter</a>
|
||||||
|
<a href="/changelog">Changelog</a>
|
||||||
|
<a href="/fight_spam">Internet Safety</a>
|
||||||
|
|
||||||
|
<h3>Community Walls</h3>
|
||||||
|
<a href="/route/i_was_here">I Was Here</a>
|
||||||
|
<a href="/route/banner_museum">Banner Museum</a>
|
||||||
|
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
|
||||||
|
|
||||||
|
<h3>Rules & Policies</h3>
|
||||||
|
<a href="/tos">Terms of Service</a>
|
||||||
|
<a href="/privacy">Privacy</a>
|
||||||
|
<a href="/coc">Code of Conduct</a>
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
|
||||||
|
<h3>Support</h3>
|
||||||
|
<a href="/help/#contact">Contact Us</a>
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="/browse/"><div class="sprite-paw menu-space-saver hideonmobile"></div>Browse</a></li>
|
||||||
|
<li class="lileft"><a class="top-heading hideondesktop" href="/search/">Search</a></li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="/submit/"><div class="sprite-upload menu-space-saver hideonmobile"></div> Upload</a></li>
|
||||||
|
|
||||||
|
|
||||||
|
<li class="lileft"><a class="top-heading" href="/plus/" title="FA+"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"><span class="hidebrowselowres"> FA+</span></a></li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="https://shop.furaffinity.net" title="Shop" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"><span class="hidebrowselowres"> Shop</span></a></li>
|
||||||
|
|
||||||
|
<div class="lileft hideonmobile">
|
||||||
|
<form id="searchbox" method="get" action="/search/">
|
||||||
|
<input type="search" name="q" placeholder="SEARCH">
|
||||||
|
<a href="/search"> </a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="no-sub">
|
||||||
|
<span class="top-heading"><div class="inline hideonmobile hideontablet">
|
||||||
|
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline hideondesktop">
|
||||||
|
<a href="/login">Log In</a><br>
|
||||||
|
<a href="/register">Create an Account</a>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(['init_sfw_button', '.sfw-toggle']);
|
||||||
|
</script>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function(){
|
||||||
|
// all menus that should be opened only one at a time
|
||||||
|
$$('.css-menu-toggle.only-one').invoke('observe', 'click', function(evt) {
|
||||||
|
var curr_input = $(evt.findElement('label').getAttribute('for'));
|
||||||
|
curr_input.next('.nav-ac-content').removeClassName('no-transition');
|
||||||
|
if(curr_input.checked === false) {
|
||||||
|
$$('.css-menu-toggle.only-one').each(function(elm){
|
||||||
|
var elm_input = $(elm.getAttribute('for'));
|
||||||
|
if(elm_input.checked === true) {
|
||||||
|
elm_input.next('.nav-ac-content').addClassName('no-transition');
|
||||||
|
elm_input.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="news-block">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="main-window" class="footer-mobile-tweak g-wrapper">
|
||||||
|
|
||||||
|
<div id="header">
|
||||||
|
<!-- site banner -->
|
||||||
|
<site-banner >
|
||||||
|
<map name="banner-map">
|
||||||
|
<area
|
||||||
|
shape="rect"
|
||||||
|
coords="441,144,1042,197"
|
||||||
|
href="https://link.vgen.co/furaffinity"
|
||||||
|
onclick="javascript: return(confirm(`You've clicked on a usemap link that will redirect you to\n\nhttps://link.vgen.co/furaffinity\n\nAre you fine with being redirected?`))"
|
||||||
|
target="_blank" />
|
||||||
|
</map>
|
||||||
|
<a href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.webp" type="image/webp">
|
||||||
|
<img usemap="#banner-map" src="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.jpg">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</site-banner>
|
||||||
|
|
||||||
|
<a name="top"></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="site-content">
|
||||||
|
<!-- /header -->
|
||||||
|
|
||||||
|
<!-- {redirect} -->
|
||||||
|
<div id="standardpage">
|
||||||
|
|
||||||
|
<section class="aligncenter notice-message user-submitted-links">
|
||||||
|
<div class="section-body alignleft">
|
||||||
|
<h2>System Message</h2>
|
||||||
|
|
||||||
|
<div class="redirect-message"><p class="link-override">The owner of this page has elected to make it available to registered users only.<br />To view the contents of this page please <a href="/login?ref=%2Fuser%2Fsoxx-thefennec%2F">log in</a> or <a href="/register/">create an account</a>.</p></div>
|
||||||
|
|
||||||
|
<div class="proceed-btn-container">
|
||||||
|
<a class="button standard go" href="javascript: history.back(-1)">Continue »</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- /<div id="site-content"> -->
|
||||||
|
|
||||||
|
<div id="footer">
|
||||||
|
<div class="auto_link footer-links">
|
||||||
|
<a href="/advertising">Advertise</a> |
|
||||||
|
<a href="/plus"><img style="position:relative;top:4px" src="/themes/beta/img/the-golden-pawb.png"> Get FA+</a> |
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a> |
|
||||||
|
<a href="https://shop.furaffinity.net/"><img style="position:relative;top:4px" src="/themes/beta/img/icons/merch_store_icon.png"> Merch Store</a> |
|
||||||
|
<a href="/route/i_was_here">Memorial</a> |
|
||||||
|
<a href="/tos">Terms of Service</a> |
|
||||||
|
<a href="/privacy">Privacy</a> |
|
||||||
|
<a href="/coc">Code of Conduct</a> |
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="footerAds">
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_left"></ins> </div>
|
||||||
|
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<div class="footerAds__slot footerAds__slot--faLogo">
|
||||||
|
<img src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<ins class="footerAds__slot format--faSmallRectangle jsAdSlot hidden" data-id="footer_right_top"></ins>
|
||||||
|
<ins class="footerAds__slot format--faSmallRectangle jsAdSlot hidden" data-id="footer_right_bottom"></ins>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="online-stats">
|
||||||
|
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> —
|
||||||
|
3334 <strong>guests</strong>,
|
||||||
|
8900 <strong>registered</strong>
|
||||||
|
and 76340 <strong>other</strong>
|
||||||
|
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
|
||||||
|
</div>
|
||||||
|
<small>Limit bot activity to periods with less than 10k registered users online.</small>
|
||||||
|
|
||||||
|
<br><br>
|
||||||
|
<strong>© 2005-2026 Frost Dragon Art LLC</strong>
|
||||||
|
|
||||||
|
<div class="footnote">
|
||||||
|
Server Time: May 24, 2026 04:31 AM<br />
|
||||||
|
Page generated in 0.067 seconds<br />[ 89.6% PHP, 10.4% SQL ] (20 queries)<br />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function() {
|
||||||
|
var exists = getCookie('sz');
|
||||||
|
var saved = save_viewport_size();
|
||||||
|
if((!exists && saved) || (exists && saved && exists != saved)) {
|
||||||
|
//window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/prebid10.26.0.js"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/censor.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/prototype.1.7.3.min.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/script.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/color_helper.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
|
||||||
|
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var server_timestamp = 1779622298;
|
||||||
|
var client_timestamp = Date.now() / 1000;
|
||||||
|
var server_timestamp_delta = server_timestamp - client_timestamp;
|
||||||
|
var sfw_cookie_name = 'sfw';
|
||||||
|
var news_cookie_name = 'n';
|
||||||
|
|
||||||
|
//
|
||||||
|
document.addEventListener("DOMContentLoaded", (event) => {
|
||||||
|
//
|
||||||
|
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":40,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":56,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":25,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":53,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":49,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":49,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":28,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":74,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":62,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":59,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":65,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":68,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":65,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":68,"tagSize":[320,50]}},"front_page":{"default":{"tagId":77,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":78,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function() {
|
||||||
|
var ddmenuOptions = {
|
||||||
|
menuId: "ddmenu",
|
||||||
|
linkIdToMenuHtml: null,
|
||||||
|
open: "onmouseover", // or "onclick"
|
||||||
|
delay: 1,
|
||||||
|
speed: 1,
|
||||||
|
keysNav: true,
|
||||||
|
license: "2c1f72"
|
||||||
|
};
|
||||||
|
var ddmenu = new Ddmenu(ddmenuOptions);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<!---
|
||||||
|
|\ /|
|
||||||
|
/_^ ^_\
|
||||||
|
\v/
|
||||||
|
|
||||||
|
The fox goes "moo!"
|
||||||
|
--->
|
||||||
|
|
||||||
|
</html>
|
||||||
2275
testdata/html/user_with_banner.html
vendored
Normal file
2275
testdata/html/user_with_banner.html
vendored
Normal file
File diff suppressed because one or more lines are too long
457
testdata/html/user_with_shouts.html
vendored
Normal file
457
testdata/html/user_with_shouts.html
vendored
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="no-js" xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
|
||||||
|
<title>Userpage of SoXX-TheFennec -- Fur Affinity [dot] net</title>
|
||||||
|
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Fur Affinity | For all things fluff, scaled, and feathered!" />
|
||||||
|
<meta name="keywords" content="fur furry furries fursuit fursuits cosplay brony bronies zootopia scalies kemono anthro anthropormophic art online gallery portfolio" />
|
||||||
|
<meta name="distribution" content="global" />
|
||||||
|
<meta name="copyright" content="Frost Dragon Art LLC" />
|
||||||
|
<meta name="robots" content="noai, noimageai" />
|
||||||
|
|
||||||
|
<link rel="icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
|
||||||
|
<link rel="shortcut icon" href="/themes/beta/img/favicon.ico" type="image/x-icon" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,500,500i,600,600i,700,700i" rel="stylesheet">
|
||||||
|
<link href="//d.furaffinity.net/media/static/img/icons/bootstrap/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=9; IE=EDGE" />
|
||||||
|
|
||||||
|
<!-- generic -->
|
||||||
|
<meta name="robots" content="noindex" />
|
||||||
|
<!-- og -->
|
||||||
|
<meta property="og:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
|
||||||
|
<!-- twitter -->
|
||||||
|
<meta name="twitter:image" content="https://www.furaffinity.net/themes/beta/img/banners/fa_logo.png?v2" />
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var _faurl = {
|
||||||
|
d: '//d.furaffinity.net',
|
||||||
|
s: '//d.furaffinity.net/media/static',
|
||||||
|
a: '//a.furaffinity.net',
|
||||||
|
r: '//rv.furaffinity.net',
|
||||||
|
t: '//t.furaffinity.net',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/common.js?u=2026050915"></script>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/themes/beta/css/ui_theme_dark.css?u=2026050915" id="css-theme" />
|
||||||
|
|
||||||
|
<!-- browser hints -->
|
||||||
|
<link rel="preconnect" href="//t.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="//a.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="//rv.furaffinity.net" />
|
||||||
|
<link rel="preconnect" href="https://www15.smartadserver.com" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/censor.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/prototype.1.7.3.min.js" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/script.js?u=2026050915" as="script" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/color_helper.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915" as="script" />
|
||||||
|
<link rel="preload" href="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915" as="script" />
|
||||||
|
|
||||||
|
<link rel="preload" href="/themes/beta/js/prebid10.26.0.js" as="script" />
|
||||||
|
|
||||||
|
<!-- Playwire Pre-connect <head> links -->
|
||||||
|
<link rel="preconnect" href="https://cdn.intergient.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://cdn.intergi.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://config.playwire.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://securepubads.g.doubleclick.net" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://z.moatads.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://cdn.playwire.com" crossorigin />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<!-- EU request: yes -->
|
||||||
|
<body class="c-bodyColor"
|
||||||
|
id="pageid-redirect" data-static-path="/themes/beta"
|
||||||
|
>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
0; // attempted fix for fouc in ff
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- sidebar -->
|
||||||
|
<div class="mobile-navigation">
|
||||||
|
|
||||||
|
<div class="mobile-nav-container">
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item left">
|
||||||
|
<label for="mobile-menu-nav" class="css-menu-toggle only-one"><img class="burger-menu" src="/themes/beta/img/fa-burger-menu-icon.png"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item center">
|
||||||
|
<a class="mobile-nav-logo" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-nav-container-item right">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-ac-container">
|
||||||
|
<input id="mobile-menu-nav" name="accordion-1" type="checkbox" />
|
||||||
|
<article class="nav-ac-content mobile-menu">
|
||||||
|
|
||||||
|
<div class="mobile-nav-content-container">
|
||||||
|
|
||||||
|
<div class="aligncenter">
|
||||||
|
<a href="/plus/"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"> FA+</a> |
|
||||||
|
<a href="https://shop.furaffinity.net" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"> Shop</a>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<h2><a href="/browse/">Browse</a></h2>
|
||||||
|
<h2><a href="/search/">Search</a></h2>
|
||||||
|
|
||||||
|
<div class="nav-ac-container">
|
||||||
|
<label for="mobile-menu-submenu-0"><h2 style="margin-top:0;padding-top:0">Support ▼</h2></label>
|
||||||
|
<input id="mobile-menu-submenu-0" name="accordion-1" type="checkbox" />
|
||||||
|
<article class="nav-ac-content nav-ac-content-dropdown">
|
||||||
|
<a href="/journals/fender">News & Updates</a><br>
|
||||||
|
<a href="/journals/rednef">Events & Spotlight</a><br>
|
||||||
|
<a href="/help/">Help & Support</a><br>
|
||||||
|
<a href="/advertising.html">Advertising</a><br>
|
||||||
|
<a href="/blm">Black Lives Matter</a><br />
|
||||||
|
<a href="/changelog">Changelog</a><br />
|
||||||
|
<a href="/fight_spam">Internet Safety</a><br />
|
||||||
|
|
||||||
|
<h3>Community Walls</h3>
|
||||||
|
<a href="/route/i_was_here">I Was Here</a><br />
|
||||||
|
<a href="/route/banner_museum">Banner Museum</a><br />
|
||||||
|
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
|
||||||
|
|
||||||
|
<h3>SUPPORT FA</h3>
|
||||||
|
<a href="/plus/">Subscribe to FA+ </a><br>
|
||||||
|
<a href="https://shop.furaffinity.net/" target="_blank">FA Merch Store</a>
|
||||||
|
|
||||||
|
|
||||||
|
<h3>RULES & POLICIES</h3>
|
||||||
|
<a href="/tos">Terms of Service</a><br>
|
||||||
|
<a href="/privacy">Privacy</a><br>
|
||||||
|
<a href="/coc">Code of Conduct</a><br>
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
|
||||||
|
<h3>SUPPORT</h3>
|
||||||
|
<a href="/help/#contact">Contact Us</a><br />
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2><div class="inline hideonmobile hideontablet">
|
||||||
|
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline hideondesktop">
|
||||||
|
<a href="/login">Log In</a><br>
|
||||||
|
<a href="/register">Create an Account</a>
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
|
||||||
|
<h2></h2>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<nav id="ddmenu">
|
||||||
|
<div class="mobile-nav navhideondesktop hideonmobile hideontablet">
|
||||||
|
<div class="mobile-nav-logo">
|
||||||
|
<a class="mobile-nav-logo" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-nav-header-item"><a href="/browse/">Browse</a></div>
|
||||||
|
<div class="mobile-nav-header-item"><a href="/search/">Search</a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-icon"></div>
|
||||||
|
|
||||||
|
<ul class="navhideonmobile">
|
||||||
|
<li class="lileft">
|
||||||
|
<div class="lileft hideonmobile" style="vertical-align:middle;line-height:0 !important" >
|
||||||
|
<a class="top-heading" href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/themes/beta/img/banners/fa_logo.webp?v2" type="image/webp">
|
||||||
|
<img class="site-logo" src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</picture> </a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="lileft">
|
||||||
|
<a class="top-heading" href="#"><div class="sprite-news menu-space-saver hideonmobile"></div>Support</a>
|
||||||
|
<i class="caret"></i>
|
||||||
|
<div class="dropdown dropdown-left ">
|
||||||
|
<div class="dd-inner">
|
||||||
|
<div class="column">
|
||||||
|
<h3>Community</h3>
|
||||||
|
<a href="/journals/fender">News & Updates</a>
|
||||||
|
<a href="/journals/rednef">Events & Spotlight</a>
|
||||||
|
<a href="/help/">Help & Support</a>
|
||||||
|
<a href="/advertising.html">Advertising</a>
|
||||||
|
<a href="/blm/">Black Lives Matter</a>
|
||||||
|
<a href="/changelog">Changelog</a>
|
||||||
|
<a href="/fight_spam">Internet Safety</a>
|
||||||
|
|
||||||
|
<h3>Community Walls</h3>
|
||||||
|
<a href="/route/i_was_here">I Was Here</a>
|
||||||
|
<a href="/route/banner_museum">Banner Museum</a>
|
||||||
|
<a href="/route/wall_of_awesome">Wall-o-Awesome</a>
|
||||||
|
|
||||||
|
<h3>Rules & Policies</h3>
|
||||||
|
<a href="/tos">Terms of Service</a>
|
||||||
|
<a href="/privacy">Privacy</a>
|
||||||
|
<a href="/coc">Code of Conduct</a>
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
|
||||||
|
<h3>Support</h3>
|
||||||
|
<a href="/help/#contact">Contact Us</a>
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="/browse/"><div class="sprite-paw menu-space-saver hideonmobile"></div>Browse</a></li>
|
||||||
|
<li class="lileft"><a class="top-heading hideondesktop" href="/search/">Search</a></li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="/submit/"><div class="sprite-upload menu-space-saver hideonmobile"></div> Upload</a></li>
|
||||||
|
|
||||||
|
|
||||||
|
<li class="lileft"><a class="top-heading" href="/plus/" title="FA+"><img class="menu-mini-icon" src="/themes/beta/img/the-golden-pawb.png"><span class="hidebrowselowres"> FA+</span></a></li>
|
||||||
|
<li class="lileft"><a class="top-heading" href="https://shop.furaffinity.net" title="Shop" target="_blank"><img class="menu-mini-icon" src="/themes/beta/img/icons/merch_store_icon.png"><span class="hidebrowselowres"> Shop</span></a></li>
|
||||||
|
|
||||||
|
<div class="lileft hideonmobile">
|
||||||
|
<form id="searchbox" method="get" action="/search/">
|
||||||
|
<input type="search" name="q" placeholder="SEARCH">
|
||||||
|
<a href="/search"> </a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="no-sub">
|
||||||
|
<span class="top-heading"><div class="inline hideonmobile hideontablet">
|
||||||
|
<a href="/login"><strong>Log In</strong></a> or <a href="/register"><strong>Create an Account</strong></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline hideondesktop">
|
||||||
|
<a href="/login">Log In</a><br>
|
||||||
|
<a href="/register">Create an Account</a>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(['init_sfw_button', '.sfw-toggle']);
|
||||||
|
</script>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function(){
|
||||||
|
// all menus that should be opened only one at a time
|
||||||
|
$$('.css-menu-toggle.only-one').invoke('observe', 'click', function(evt) {
|
||||||
|
var curr_input = $(evt.findElement('label').getAttribute('for'));
|
||||||
|
curr_input.next('.nav-ac-content').removeClassName('no-transition');
|
||||||
|
if(curr_input.checked === false) {
|
||||||
|
$$('.css-menu-toggle.only-one').each(function(elm){
|
||||||
|
var elm_input = $(elm.getAttribute('for'));
|
||||||
|
if(elm_input.checked === true) {
|
||||||
|
elm_input.next('.nav-ac-content').addClassName('no-transition');
|
||||||
|
elm_input.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="news-block">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="main-window" class="footer-mobile-tweak g-wrapper">
|
||||||
|
|
||||||
|
<div id="header">
|
||||||
|
<!-- site banner -->
|
||||||
|
<site-banner >
|
||||||
|
<map name="banner-map">
|
||||||
|
<area
|
||||||
|
shape="rect"
|
||||||
|
coords="441,144,1042,197"
|
||||||
|
href="https://link.vgen.co/furaffinity"
|
||||||
|
onclick="javascript: return(confirm(`You've clicked on a usemap link that will redirect you to\n\nhttps://link.vgen.co/furaffinity\n\nAre you fine with being redirected?`))"
|
||||||
|
target="_blank" />
|
||||||
|
</map>
|
||||||
|
<a href="/">
|
||||||
|
<picture>
|
||||||
|
<source srcset="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.webp" type="image/webp">
|
||||||
|
<img usemap="#banner-map" src="//d.furaffinity.net/media/banners/modern/fa-banner-spring-vgen-20260501.jpg">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</site-banner>
|
||||||
|
|
||||||
|
<a name="top"></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="site-content">
|
||||||
|
<!-- /header -->
|
||||||
|
|
||||||
|
<!-- {redirect} -->
|
||||||
|
<div id="standardpage">
|
||||||
|
|
||||||
|
<section class="aligncenter notice-message user-submitted-links">
|
||||||
|
<div class="section-body alignleft">
|
||||||
|
<h2>System Message</h2>
|
||||||
|
|
||||||
|
<div class="redirect-message"><p class="link-override">The owner of this page has elected to make it available to registered users only.<br />To view the contents of this page please <a href="/login?ref=%2Fuser%2Fsoxx-thefennec%2F">log in</a> or <a href="/register/">create an account</a>.</p></div>
|
||||||
|
|
||||||
|
<div class="proceed-btn-container">
|
||||||
|
<a class="button standard go" href="javascript: history.back(-1)">Continue »</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- /<div id="site-content"> -->
|
||||||
|
|
||||||
|
<div id="footer">
|
||||||
|
<div class="auto_link footer-links">
|
||||||
|
<a href="/advertising">Advertise</a> |
|
||||||
|
<a href="/plus"><img style="position:relative;top:4px" src="/themes/beta/img/the-golden-pawb.png"> Get FA+</a> |
|
||||||
|
<a href="https://status.furaffinity.net/">Site Status</a> |
|
||||||
|
<a href="https://shop.furaffinity.net/"><img style="position:relative;top:4px" src="/themes/beta/img/icons/merch_store_icon.png"> Merch Store</a> |
|
||||||
|
<a href="/route/i_was_here">Memorial</a> |
|
||||||
|
<a href="/tos">Terms of Service</a> |
|
||||||
|
<a href="/privacy">Privacy</a> |
|
||||||
|
<a href="/coc">Code of Conduct</a> |
|
||||||
|
<a href="/aup">Upload Policy</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="footerAds">
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_left"></ins> </div>
|
||||||
|
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<div class="footerAds__slot footerAds__slot--faLogo">
|
||||||
|
<img src="/themes/beta/img/banners/fa_logo.png?v2">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footerAds__column">
|
||||||
|
<ins class="footerAds__slot format--faMediumRectangle jsAdSlot hidden" data-id="footer_right"></ins>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="online-stats">
|
||||||
|
88574 <strong><span title="Measured in the last 900 seconds">Users online</span></strong> —
|
||||||
|
3334 <strong>guests</strong>,
|
||||||
|
8900 <strong>registered</strong>
|
||||||
|
and 76340 <strong>other</strong>
|
||||||
|
<!-- Online Counter Last Update: Sun, 24 May 2026 04:31:01 -0700 -->
|
||||||
|
</div>
|
||||||
|
<small>Limit bot activity to periods with less than 10k registered users online.</small>
|
||||||
|
|
||||||
|
<br><br>
|
||||||
|
<strong>© 2005-2026 Frost Dragon Art LLC</strong>
|
||||||
|
|
||||||
|
<div class="footnote">
|
||||||
|
Server Time: May 24, 2026 04:31 AM<br />
|
||||||
|
Page generated in 0.058 seconds<br />[ 83.8% PHP, 16.2% SQL ] (20 queries)<br />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function() {
|
||||||
|
var exists = getCookie('sz');
|
||||||
|
var saved = save_viewport_size();
|
||||||
|
if((!exists && saved) || (exists && saved && exists != saved)) {
|
||||||
|
//window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/prebid10.26.0.js"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/censor.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/prototype.1.7.3.min.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/script.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/color_helper.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/bbcode_color_adjuster.js?u=2026050915"></script>
|
||||||
|
<script type="text/javascript" src="/themes/beta/js/widgets/fuzzy-date-switcher.js?u=2026050915"></script>
|
||||||
|
|
||||||
|
|
||||||
|
<script src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var server_timestamp = 1779622299;
|
||||||
|
var client_timestamp = Date.now() / 1000;
|
||||||
|
var server_timestamp_delta = server_timestamp - client_timestamp;
|
||||||
|
var sfw_cookie_name = 'sfw';
|
||||||
|
var news_cookie_name = 'n';
|
||||||
|
|
||||||
|
//
|
||||||
|
document.addEventListener("DOMContentLoaded", (event) => {
|
||||||
|
//
|
||||||
|
const ad_manager = new adManager({"sizeConfig":[{"labels":["desktopWide"],"mediaQuery":"(min-width: 1090px)","sizesSupported":[[728,90],[300,250],[300,168],[300,600],[160,600]]},{"labels":["desktopNarrow"],"mediaQuery":"(min-width: 740px) and (max-width: 1089px)","sizesSupported":[[728,90],[300,250],[300,168]]},{"labels":["mobile"],"mediaQuery":"(min-width: 0px) and (max-width: 739px)","sizesSupported":[[320,50],[300,50],[320,100]]}],"slotConfig":{"header_middle":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"above_content":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"sidebar":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]},"sidebar_tall":{"containerSize":{"desktopWide":[300,600],"desktopNarrow":[300,600],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_left":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right":{"containerSize":{"desktopWide":[300,250],"desktopNarrow":[300,250],"mobile":[300,250]},"providerPriority":["inhouse"]},"footer_right_top":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"footer_right_bottom":{"containerSize":{"desktopWide":[320,50],"desktopNarrow":[320,50],"mobile":[320,50]},"providerPriority":["inhouse"]},"header_right_left":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"header_right_right":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_top":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"sidebar_bottom":{"containerSize":{"desktopWide":[320,50]},"providerPriority":["inhouse"]},"front_page":{"containerSize":{"desktopWide":[728,90],"desktopNarrow":[728,90],"mobile":[320,50]},"providerPriority":["inhouse"]},"c-videoAd":{"containerSize":{"desktopWide":[300,250]},"providerPriority":["inhouse"]}},"providerConfig":{"inhouse":{"domain":"https:\/\/rv.furaffinity.net","dataPath":"\/live\/www\/delivery\/spc.php","dataVariableName":"OA_output"}},"adConfig":{"inhouse":{"header_middle":{"default":{"tagId":40,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":56,"tagSize":[320,50]}}},"above_content":{"default":{"tagId":25,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":53,"tagSize":[320,50]}}},"sidebar":{"default":{"tagId":49,"tagSize":[300,250]}},"sidebar_tall":{"default":{"tagId":49,"tagSize":[300,250]}},"footer_left":{"default":{"tagId":28,"tagSize":[300,250]}},"footer_right":{"default":{"tagId":74,"tagSize":[300,250]}},"footer_right_top":{"default":{"tagId":62,"tagSize":[320,50]}},"footer_right_bottom":{"default":{"tagId":59,"tagSize":[320,50]}},"header_right_left":{"default":{"tagId":65,"tagSize":[320,50]}},"header_right_right":{"default":{"tagId":68,"tagSize":[320,50]}},"sidebar_top":{"default":{"tagId":65,"tagSize":[320,50]}},"sidebar_bottom":{"default":{"tagId":68,"tagSize":[320,50]}},"front_page":{"default":{"tagId":77,"tagSize":[728,90]},"sizeOverride":{"mobile":{"tagId":78,"tagSize":[320,50]}}},"c-videoAd":{"default":{"tagId":71,"tagSize":[320,50]}}}},"extraMetadata":{"adsenseClient":"ca-pub-3495616356562362","forceLoadConfigs":["c-videoAd"]}}, true);
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
_fajs.push(function() {
|
||||||
|
var ddmenuOptions = {
|
||||||
|
menuId: "ddmenu",
|
||||||
|
linkIdToMenuHtml: null,
|
||||||
|
open: "onmouseover", // or "onclick"
|
||||||
|
delay: 1,
|
||||||
|
speed: 1,
|
||||||
|
keysNav: true,
|
||||||
|
license: "2c1f72"
|
||||||
|
};
|
||||||
|
var ddmenu = new Ddmenu(ddmenuOptions);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<!---
|
||||||
|
|\ /|
|
||||||
|
/_^ ^_\
|
||||||
|
\v/
|
||||||
|
|
||||||
|
The fox goes "moo!"
|
||||||
|
--->
|
||||||
|
|
||||||
|
</html>
|
||||||
64
time.go
Normal file
64
time.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// faDateLayouts lists every layout FA has been observed emitting in
|
||||||
|
// title= attributes of date elements. Tried in order.
|
||||||
|
var faDateLayouts = []string{
|
||||||
|
"January 2, 2006 03:04:05 PM", // "March 23, 2026 09:01:08 AM" current beta popup_date title
|
||||||
|
"January 2, 2006 3:04:05 PM",
|
||||||
|
"Jan 2, 2006 03:04:05 PM", // 3-letter month variant
|
||||||
|
"Jan 2, 2006 3:04:05 PM",
|
||||||
|
"Jan 2, 2006 03:04 PM", // legacy beta layout (no seconds)
|
||||||
|
"Jan 2, 2006 3:04 PM",
|
||||||
|
"2006-01-02T15:04:05Z07:00",
|
||||||
|
time.RFC3339,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFADate parses a FurAffinity-formatted date string. FA renders dates
|
||||||
|
// either as a "popup" with the full timestamp in a title attribute, or as a
|
||||||
|
// relative phrase ("5 hours ago") in the visible text. Callers should pass
|
||||||
|
// the title attribute when available.
|
||||||
|
//
|
||||||
|
// FA does not include timezone information in its displayed format; the site
|
||||||
|
// uses server-local time historically labelled as UTC-7. We treat parsed
|
||||||
|
// values as UTC because that is what the SDK consistently exposes callers
|
||||||
|
// who need a wall-clock display should convert.
|
||||||
|
func ParseFADate(s string) (time.Time, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}, fmt.Errorf("parse fa date: empty string")
|
||||||
|
}
|
||||||
|
cleaned := stripOrdinals(s)
|
||||||
|
for _, layout := range faDateLayouts {
|
||||||
|
if t, err := time.ParseInLocation(layout, cleaned, time.UTC); err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, fmt.Errorf("parse fa date %q: no matching layout", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripOrdinals removes English ordinal suffixes (st, nd, rd, th) from a date
|
||||||
|
// string so it can be parsed by Go's reference layout. "Mar 17th, 2026" →
|
||||||
|
// "Mar 17, 2026".
|
||||||
|
func stripOrdinals(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(s))
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
if (c >= '0' && c <= '9') && i+2 < len(s) {
|
||||||
|
next2 := strings.ToLower(s[i+1 : i+3])
|
||||||
|
if next2 == "st" || next2 == "nd" || next2 == "rd" || next2 == "th" {
|
||||||
|
b.WriteByte(c)
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteByte(c)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
77
time_test.go
Normal file
77
time_test.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseFADate(t *testing.T) {
|
||||||
|
must := func(layout, s string) time.Time {
|
||||||
|
t.Helper()
|
||||||
|
v, err := time.ParseInLocation(layout, s, time.UTC)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup: parse %q: %v", s, err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want time.Time
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "standard popup_date",
|
||||||
|
in: "Mar 17, 2026 04:21 PM",
|
||||||
|
want: must("Jan 2, 2006 03:04 PM", "Mar 17, 2026 04:21 PM"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ordinal suffix",
|
||||||
|
in: "Mar 17th, 2026 04:21 PM",
|
||||||
|
want: must("Jan 2, 2006 03:04 PM", "Mar 17, 2026 04:21 PM"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no padding",
|
||||||
|
in: "Mar 7, 2026 4:21 PM",
|
||||||
|
want: must("Jan 2, 2006 3:04 PM", "Mar 7, 2026 4:21 PM"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rfc3339",
|
||||||
|
in: "2026-03-17T16:21:00Z",
|
||||||
|
want: must(time.RFC3339, "2026-03-17T16:21:00Z"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := ParseFADate(tc.in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseFADate(%q) error = %v", tc.in, err)
|
||||||
|
}
|
||||||
|
if !got.Equal(tc.want) {
|
||||||
|
t.Errorf("ParseFADate(%q) = %s; want %s", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFADate_Empty(t *testing.T) {
|
||||||
|
if _, err := ParseFADate(""); err == nil {
|
||||||
|
t.Fatal("expected error for empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripOrdinals(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"Mar 1st, 2026": "Mar 1, 2026",
|
||||||
|
"Mar 2nd, 2026": "Mar 2, 2026",
|
||||||
|
"Mar 3rd, 2026": "Mar 3, 2026",
|
||||||
|
"Mar 4th, 2026": "Mar 4, 2026",
|
||||||
|
"already clean": "already clean",
|
||||||
|
"Mar 1st 2nd": "Mar 1 2",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := stripOrdinals(in); got != want {
|
||||||
|
t.Errorf("stripOrdinals(%q) = %q; want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
222
transport.go
Normal file
222
transport.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// transport is the SDK's http.RoundTripper. It is the only place where
|
||||||
|
// rate limiting, header injection, Cloudflare detection, and retries are
|
||||||
|
// enforced callers cannot bypass it because the *http.Client wired into
|
||||||
|
// Colly is built around it. Cookies live on the *http.Client's Jar, not here.
|
||||||
|
type transport struct {
|
||||||
|
base http.RoundTripper
|
||||||
|
limiter *rateLimiter
|
||||||
|
userAgent string
|
||||||
|
maxRetries int
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultMaxRetries is the cap on automatic 429/5xx retries per request.
|
||||||
|
// Three retries with exponential backoff (1s/2s/4s) is enough to absorb
|
||||||
|
// short blips without masking real outages.
|
||||||
|
const defaultMaxRetries = 3
|
||||||
|
|
||||||
|
// RoundTrip implements http.RoundTripper. It gates on the rate limiter,
|
||||||
|
// injects the User-Agent header, retries on transient failures, and
|
||||||
|
// classifies Cloudflare challenges as non-retryable user-actionable errors.
|
||||||
|
func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
if t.userAgent != "" && req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", t.userAgent)
|
||||||
|
}
|
||||||
|
// FA serves Brotli-encoded HTML only to UAs that advertise it; the stdlib
|
||||||
|
// transport handles gzip transparently when we don't set this header
|
||||||
|
// ourselves, so leave it alone.
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt <= t.maxRetries; attempt++ {
|
||||||
|
waitStart := time.Now()
|
||||||
|
if err := t.limiter.wait(req.Context()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
waitMs := time.Since(waitStart).Milliseconds()
|
||||||
|
|
||||||
|
// Each attempt needs an independent body reader; if the caller passed
|
||||||
|
// one we trust them to have provided GetBody (stdlib does this for
|
||||||
|
// the common cases). Without a body, this branch is a no-op.
|
||||||
|
if attempt > 0 && req.GetBody != nil {
|
||||||
|
body, err := req.GetBody()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Body = body
|
||||||
|
}
|
||||||
|
|
||||||
|
rtStart := time.Now()
|
||||||
|
resp, err := t.base.RoundTrip(req)
|
||||||
|
durMs := time.Since(rtStart).Milliseconds()
|
||||||
|
t.logRequest(req, resp, err, durMs, waitMs)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
if !isTransientNetErr(err) || attempt == t.maxRetries {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.sleepBackoff(attempt)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if isCloudflareChallenge(resp) {
|
||||||
|
drainAndClose(resp.Body)
|
||||||
|
return nil, ErrCloudflareChallenge
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case resp.StatusCode == http.StatusTooManyRequests:
|
||||||
|
drainAndClose(resp.Body)
|
||||||
|
if attempt == t.maxRetries {
|
||||||
|
return nil, ErrRateLimited
|
||||||
|
}
|
||||||
|
t.sleepRetryAfter(resp, attempt)
|
||||||
|
continue
|
||||||
|
|
||||||
|
case resp.StatusCode >= 500 && resp.StatusCode <= 599:
|
||||||
|
drainAndClose(resp.Body)
|
||||||
|
if attempt == t.maxRetries {
|
||||||
|
return nil, &HTTPError{StatusCode: resp.StatusCode, URL: req.URL.String()}
|
||||||
|
}
|
||||||
|
t.sleepBackoff(attempt)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
return nil, errors.New("fa: transport exhausted retries without a response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// logRequest emits one structured slog record per HTTP round-trip so a
|
||||||
|
// consumer can trace request timings and rate-limit waits. Only the URL host
|
||||||
|
// is logged never the path or query to avoid leaking what was fetched.
|
||||||
|
func (t *transport) logRequest(req *http.Request, resp *http.Response, err error, durMs, waitMs int64) {
|
||||||
|
if t.logger == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var host string
|
||||||
|
if req != nil && req.URL != nil {
|
||||||
|
host = req.URL.Host
|
||||||
|
}
|
||||||
|
status := 0
|
||||||
|
if err == nil && resp != nil {
|
||||||
|
status = resp.StatusCode
|
||||||
|
}
|
||||||
|
// InfoContext (not Info) so the request's context propagates to the slog
|
||||||
|
// handler. A tracing consumer can carry an active span in that context
|
||||||
|
// and nest this HTTP request as a child span of it.
|
||||||
|
ctx := context.Background()
|
||||||
|
if req != nil {
|
||||||
|
ctx = req.Context()
|
||||||
|
}
|
||||||
|
t.logger.InfoContext(ctx, "fa.request",
|
||||||
|
"host", host,
|
||||||
|
"durationMs", durMs,
|
||||||
|
"status", status,
|
||||||
|
"rateWaitMs", waitMs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sleepBackoff sleeps for an exponential interval based on attempt index.
|
||||||
|
// attempt is 0-based, so the sequence is 1s, 2s, 4s.
|
||||||
|
func (t *transport) sleepBackoff(attempt int) {
|
||||||
|
d := time.Duration(1<<attempt) * time.Second
|
||||||
|
if t.logger != nil {
|
||||||
|
t.logger.Debug("fa: backoff", "attempt", attempt+1, "sleep", d)
|
||||||
|
}
|
||||||
|
time.Sleep(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sleepRetryAfter honours the server's Retry-After header (seconds or HTTP
|
||||||
|
// date), capping at 60s to bound worst-case latency. Falls back to
|
||||||
|
// exponential backoff if the header is missing or unparseable.
|
||||||
|
func (t *transport) sleepRetryAfter(resp *http.Response, attempt int) {
|
||||||
|
const cap = 60 * time.Second
|
||||||
|
if h := resp.Header.Get("Retry-After"); h != "" {
|
||||||
|
if secs, err := strconv.Atoi(strings.TrimSpace(h)); err == nil && secs >= 0 {
|
||||||
|
d := time.Duration(secs) * time.Second
|
||||||
|
if d > cap {
|
||||||
|
d = cap
|
||||||
|
}
|
||||||
|
if t.logger != nil {
|
||||||
|
t.logger.Debug("fa: retry-after", "sleep", d)
|
||||||
|
}
|
||||||
|
time.Sleep(d)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if when, err := http.ParseTime(h); err == nil {
|
||||||
|
d := time.Until(when)
|
||||||
|
if d < 0 {
|
||||||
|
d = time.Second
|
||||||
|
}
|
||||||
|
if d > cap {
|
||||||
|
d = cap
|
||||||
|
}
|
||||||
|
time.Sleep(d)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.sleepBackoff(attempt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCloudflareChallenge inspects a response for the signatures Cloudflare
|
||||||
|
// emits when it interposes a challenge. We treat these as non-retryable
|
||||||
|
// because the SDK has no JS engine; the caller must refresh cf_clearance.
|
||||||
|
func isCloudflareChallenge(resp *http.Response) bool {
|
||||||
|
if resp == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if v := resp.Header.Get("cf-mitigated"); strings.EqualFold(v, "challenge") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Managed challenge / IUAM page: 403 or 503 with cf-ray and HTML body.
|
||||||
|
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusServiceUnavailable {
|
||||||
|
if resp.Header.Get("cf-ray") != "" && strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTransientNetErr returns true for the kinds of network errors that are
|
||||||
|
// reasonable to retry (timeouts, EOFs from broken keepalive connections).
|
||||||
|
// Anything else DNS failures, refused connections surfaces immediately.
|
||||||
|
func isTransientNetErr(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var ne interface{ Timeout() bool }
|
||||||
|
if errors.As(err, &ne) && ne.Timeout() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainAndClose flushes the response body so the underlying TCP connection
|
||||||
|
// can be returned to the pool. Failing to do this on a retry path leaks
|
||||||
|
// connections under load.
|
||||||
|
func drainAndClose(body io.ReadCloser) {
|
||||||
|
if body == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, body)
|
||||||
|
_ = body.Close()
|
||||||
|
}
|
||||||
250
transport_test.go
Normal file
250
transport_test.go
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestTransport builds a transport that wraps a base RoundTripper for
|
||||||
|
// httptest use. Rate limit is fast (no waits between requests) so retry
|
||||||
|
// tests run in subseconds.
|
||||||
|
func newTestTransport(base http.RoundTripper, maxRetries int) *transport {
|
||||||
|
return &transport{
|
||||||
|
base: base,
|
||||||
|
limiter: newRateLimiter(time.Microsecond, 16, false),
|
||||||
|
userAgent: "test-agent",
|
||||||
|
maxRetries: maxRetries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransport_DetectsCloudflareChallenge_FromHeader(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("cf-mitigated", "challenge")
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := &http.Client{Transport: newTestTransport(http.DefaultTransport, 3)}
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||||
|
_, err := c.Do(req)
|
||||||
|
if !errors.Is(err, ErrCloudflareChallenge) {
|
||||||
|
t.Fatalf("got %v; want ErrCloudflareChallenge", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransport_DetectsCloudflareChallenge_From503CFRay(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("cf-ray", "abc123-FRA")
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
_, _ = w.Write([]byte("<html>Just a moment...</html>"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := &http.Client{Transport: newTestTransport(http.DefaultTransport, 3)}
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||||
|
_, err := c.Do(req)
|
||||||
|
if !errors.Is(err, ErrCloudflareChallenge) {
|
||||||
|
t.Fatalf("got %v; want ErrCloudflareChallenge", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransport_RetriesOn5xx_ThenSucceeds(t *testing.T) {
|
||||||
|
var hits atomic.Int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := hits.Add(1)
|
||||||
|
if n < 3 {
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Override sleepBackoff: use a transport whose limiter pace makes
|
||||||
|
// "exponential" 1s/2s waits unbearable for tests. Inject a small
|
||||||
|
// limiter and accept the 1s+2s actual sleep or short-circuit by
|
||||||
|
// using maxRetries=0 path. Instead we exercise just 1 retry by
|
||||||
|
// returning 502 once: keep test fast.
|
||||||
|
hits.Store(0)
|
||||||
|
srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := hits.Add(1)
|
||||||
|
if n == 1 {
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
|
||||||
|
c := &http.Client{Transport: newTestTransport(http.DefaultTransport, 1)}
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||||
|
resp, err := c.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Do: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d; want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if hits.Load() != 2 {
|
||||||
|
t.Fatalf("hits = %d; want 2 (initial 502 + one retry)", hits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransport_Returns429AsErrRateLimited_AfterExhaustingRetries(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Retry-After", "0") // zero seconds => fast test
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := &http.Client{Transport: newTestTransport(http.DefaultTransport, 1)}
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||||
|
_, err := c.Do(req)
|
||||||
|
if !errors.Is(err, ErrRateLimited) {
|
||||||
|
t.Fatalf("got %v; want ErrRateLimited", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransport_InjectsUserAgent(t *testing.T) {
|
||||||
|
var seen string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seen = r.Header.Get("User-Agent")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := &http.Client{Transport: newTestTransport(http.DefaultTransport, 0)}
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||||
|
resp, err := c.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Do: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if seen != "test-agent" {
|
||||||
|
t.Fatalf("UA = %q; want %q", seen, "test-agent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransport_HonorsRetryAfter(t *testing.T) {
|
||||||
|
var hits atomic.Int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := hits.Add(1)
|
||||||
|
if n == 1 {
|
||||||
|
w.Header().Set("Retry-After", "0") // 0 = retry immediately
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := &http.Client{Transport: newTestTransport(http.DefaultTransport, 2)}
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||||
|
resp, err := c.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Do: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if hits.Load() < 2 {
|
||||||
|
t.Fatalf("hits = %d; want >= 2", hits.Load())
|
||||||
|
}
|
||||||
|
if v, _ := strconv.Atoi(resp.Header.Get("Retry-After")); v != 0 && resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("unexpected final state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// traceCtxKey is a test-only context key used to smuggle a sentinel value
|
||||||
|
// through a request's context so a slog handler can prove it received it.
|
||||||
|
type traceCtxKey struct{}
|
||||||
|
|
||||||
|
// captureHandler is a minimal slog.Handler that records the context handed
|
||||||
|
// to Handle. A context-aware tracing handler reads the active span from that
|
||||||
|
// context; this test stand-in just checks the context arrives at all.
|
||||||
|
type captureHandler struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
ctx context.Context
|
||||||
|
handled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *captureHandler) Enabled(context.Context, slog.Level) bool { return true }
|
||||||
|
|
||||||
|
func (h *captureHandler) Handle(ctx context.Context, _ slog.Record) error {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.ctx = ctx
|
||||||
|
h.handled = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *captureHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
|
||||||
|
func (h *captureHandler) WithGroup(string) slog.Handler { return h }
|
||||||
|
|
||||||
|
// TestTransport_LogRequest_PropagatesRequestContext guards SDK issue #24:
|
||||||
|
// logRequest must emit its record with InfoContext(req.Context(), …), not
|
||||||
|
// Info(…), so a context-aware slog.Handler can recover the caller's active
|
||||||
|
// span and nest the HTTP span beneath it. Regressing to Info() would hand the
|
||||||
|
// handler context.Background() and orphan every HTTP span.
|
||||||
|
func TestTransport_LogRequest_PropagatesRequestContext(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
h := &captureHandler{}
|
||||||
|
tr := newTestTransport(http.DefaultTransport, 0)
|
||||||
|
tr.logger = slog.New(h)
|
||||||
|
|
||||||
|
c := &http.Client{Transport: tr}
|
||||||
|
ctx := context.WithValue(context.Background(), traceCtxKey{}, "trace-123")
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
|
||||||
|
resp, err := c.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Do: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
got, handled := h.ctx, h.handled
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
if !handled {
|
||||||
|
t.Fatal("slog handler was never called; logRequest emitted no record")
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("slog handler received a nil context")
|
||||||
|
}
|
||||||
|
if v, _ := got.Value(traceCtxKey{}).(string); v != "trace-123" {
|
||||||
|
t.Fatalf("handler context value = %q; want %q the request's context did not reach the slog record (logRequest must use InfoContext)", v, "trace-123")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransport_ContextCancellationPropagates(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Block until the request context is cancelled.
|
||||||
|
<-r.Context().Done()
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := &http.Client{Transport: newTestTransport(http.DefaultTransport, 0)}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
go func() {
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
|
||||||
|
_, err := c.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected cancellation error")
|
||||||
|
}
|
||||||
|
}
|
||||||
54
user.go
Normal file
54
user.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User is a user's full profile page, as seen on /user/{name}/.
|
||||||
|
type User struct {
|
||||||
|
UserRef
|
||||||
|
Title string // user-set "title" / artist headline
|
||||||
|
Joined time.Time // registration date
|
||||||
|
BioHTML string // raw profile HTML
|
||||||
|
BioText string // plaintext convenience
|
||||||
|
Stats UserStats // submission/view/journal/watcher counts
|
||||||
|
Contacts []UserContact // social/contact rows
|
||||||
|
Shouts []Shout // most recent shouts shown on the page
|
||||||
|
FeaturedSub *SubmissionRef // pinned/featured submission, if any
|
||||||
|
SiteBanner *SiteBanner // header banner; IsCustom=false when FA's default is shown
|
||||||
|
|
||||||
|
// Watched is true when the logged-in viewer currently watches this user
|
||||||
|
// i.e. the profile header renders an "Unwatch" button. It is false
|
||||||
|
// both when the viewer does not watch the user and when no watch button
|
||||||
|
// is present at all (anonymous session, or viewing your own page), so
|
||||||
|
// it is only meaningful on another user's page while logged in.
|
||||||
|
Watched bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser fetches a user profile by URL-safe name (FA's lowercase login form).
|
||||||
|
func (c *Client) GetUser(ctx context.Context, name string) (*User, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("fa: GetUser: empty name")
|
||||||
|
}
|
||||||
|
var out *User
|
||||||
|
err := c.fetch(ctx, urls.User(name), func(doc *goquery.Document) error {
|
||||||
|
u, err := parseUser(name, doc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = u
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
219
user_parser.go
Normal file
219
user_parser.go
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
|
||||||
|
"git.anthrove.art/public/go-fa-api/internal/urls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseUser pulls a [User] out of /user/{name}/.
|
||||||
|
//
|
||||||
|
// FA's profile page has many optional sections; parser treats the headline
|
||||||
|
// (display name + avatar) as required, everything else as best-effort.
|
||||||
|
func parseUser(name string, doc *goquery.Document) (*User, error) {
|
||||||
|
u := &User{UserRef: UserRef{Name: strings.ToLower(strings.TrimSpace(name))}}
|
||||||
|
|
||||||
|
// Headline username + avatar.
|
||||||
|
header := doc.Find("userpage-nav-user-details, div.userpage-nav-user-details, div.username").First()
|
||||||
|
if header.Length() == 0 {
|
||||||
|
header = doc.Find("h1.username, h2.username").First()
|
||||||
|
}
|
||||||
|
u.DisplayName = firstNonEmpty(
|
||||||
|
// Scope the display name to the profile header first an unscoped
|
||||||
|
// .c-usernameBlock__displayName also matches the logged-in viewer's
|
||||||
|
// block elsewhere on the page.
|
||||||
|
trimText(doc.Find("userpage-nav-user-details .js-displayName").First()),
|
||||||
|
trimText(doc.Find("userpage-nav-user-details .c-usernameBlock__displayName").First()),
|
||||||
|
trimText(doc.Find(".username h2 span").First()),
|
||||||
|
trimText(doc.Find(".username h1").First()),
|
||||||
|
trimText(doc.Find(".c-usernameBlock__displayName").First()),
|
||||||
|
trimText(doc.Find(".c-usernameBlockSimple__displayName").First()),
|
||||||
|
trimText(header),
|
||||||
|
u.Name,
|
||||||
|
)
|
||||||
|
// The profile owner's avatar lives in the <userpage-nav-avatar> header
|
||||||
|
// element. It must be scoped there: img.avatar / img.loggedin_user_avatar
|
||||||
|
// in the site navigation belong to the logged-in viewer, and an unscoped
|
||||||
|
// selector picks the viewer's avatar on every logged-in page load.
|
||||||
|
u.AvatarURL = urls.AbsoluteCDN(firstNonEmpty(
|
||||||
|
trimAttr(doc.Find("userpage-nav-avatar img").First(), "src"),
|
||||||
|
trimAttr(doc.Find("div.userpage-nav-avatar img").First(), "src"),
|
||||||
|
trimAttr(doc.Find("img.user-nav-avatar").First(), "src"),
|
||||||
|
))
|
||||||
|
if u.DisplayName == "" {
|
||||||
|
return nil, fmt.Errorf("%w: user %q: missing display name", ErrParse, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Title" headline shown under the username.
|
||||||
|
u.Title = firstNonEmpty(
|
||||||
|
trimText(doc.Find(".userpage-flex-item.username .font-small").First()),
|
||||||
|
trimText(doc.Find(".user-nav-user-details .c-usernameBlock__subtitle").First()),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Registered-on date appears in profile-meta or in a span.popup_date.
|
||||||
|
doc.Find("span.popup_date").EachWithBreak(func(_ int, sel *goquery.Selection) bool {
|
||||||
|
raw := firstNonEmpty(trimAttr(sel, "title"), trimText(sel))
|
||||||
|
if t, err := ParseFADate(raw); err == nil {
|
||||||
|
u.Joined = t
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Profile bio: large HTML block on the left column.
|
||||||
|
bio := firstNonEmptySel(doc,
|
||||||
|
"div.userpage-profile",
|
||||||
|
"div.profile-page-body",
|
||||||
|
"div.profile-description",
|
||||||
|
)
|
||||||
|
if bio != nil {
|
||||||
|
u.BioHTML = htmlOf(bio)
|
||||||
|
u.BioText = strings.TrimSpace(bio.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats: the "Stats" box in the right column is a flat run of
|
||||||
|
// <span class="highlight">Label:</span> value<br/>
|
||||||
|
// pairs inside one or more <div class="cell">. The value is the bare
|
||||||
|
// text node that immediately follows each highlight span.
|
||||||
|
doc.Find("div.userpage-section-right div.cell").Each(func(_ int, cell *goquery.Selection) {
|
||||||
|
nodes := cell.Contents()
|
||||||
|
nodes.Each(func(i int, node *goquery.Selection) {
|
||||||
|
if !node.Is("span.highlight") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
label := strings.ToLower(strings.TrimRight(trimText(node), ":"))
|
||||||
|
val := parseStatNumber(nodes.Eq(i + 1).Text())
|
||||||
|
switch label {
|
||||||
|
case "submissions":
|
||||||
|
u.Stats.Submissions = val
|
||||||
|
case "favs", "favorites":
|
||||||
|
u.Stats.Favorites = val
|
||||||
|
case "views", "page visits":
|
||||||
|
u.Stats.Views = val
|
||||||
|
case "comments earned", "comments":
|
||||||
|
u.Stats.Comments = val
|
||||||
|
case "journals":
|
||||||
|
u.Stats.Journals = val
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watcher / watching counts are NOT in the stats box FA renders them
|
||||||
|
// in the "Recent Watchers" / "Recently Watched" section headers as
|
||||||
|
// "View List (Watched by N)" / "View List (Watching N)".
|
||||||
|
u.Stats.Watchers = parseStatNumber(trimText(doc.Find("section.watched-by-block .section-header a").First()))
|
||||||
|
u.Stats.Watching = parseStatNumber(trimText(doc.Find("section.is-watching-block .section-header a").First()))
|
||||||
|
|
||||||
|
// Contact information rows.
|
||||||
|
doc.Find("div.user-contact-user-info, .userpage-contact-information li").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
site := trimText(sel.Find("span.user-contact-item-name, .contact-site").First())
|
||||||
|
linkSel := sel.Find("a").First()
|
||||||
|
handle := trimText(linkSel)
|
||||||
|
if handle == "" {
|
||||||
|
handle = strings.TrimSpace(sel.Text())
|
||||||
|
}
|
||||||
|
href, _ := linkSel.Attr("href")
|
||||||
|
if site != "" || handle != "" {
|
||||||
|
u.Contacts = append(u.Contacts, UserContact{
|
||||||
|
Site: site,
|
||||||
|
Handle: handle,
|
||||||
|
URL: urls.AbsoluteCDN(href),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Featured submission: small preview thumbnail on the profile.
|
||||||
|
if feat := doc.Find("div.userpage-featured-submission a, section.userpage-section-right figure a").First(); feat.Length() > 0 {
|
||||||
|
href, _ := feat.Attr("href")
|
||||||
|
if id := extractIntFromHref(href); id > 0 {
|
||||||
|
u.FeaturedSub = &SubmissionRef{
|
||||||
|
ID: SubmissionID(id),
|
||||||
|
Title: trimAttr(feat, "title"),
|
||||||
|
ThumbURL: urls.AbsoluteCDN(trimAttr(feat.Find("img").First(), "src")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shouts: anchored by <a id="shout-NNN"> inside a
|
||||||
|
// <div class="comment_container"> (underscore). Beta uses custom HTML5
|
||||||
|
// elements <comment-container>/<comment-username>/<comment-date>/
|
||||||
|
// <comment-user-text> within that wrapper goquery matches them by tag.
|
||||||
|
doc.Find("a[id^='shout-']").Each(func(_ int, anchor *goquery.Selection) {
|
||||||
|
container := anchor.ParentsFiltered("div.comment_container").First()
|
||||||
|
if container.Length() == 0 {
|
||||||
|
// Fallback for legacy markup where the anchor sits as a sibling
|
||||||
|
// of a table or comment-container directly.
|
||||||
|
container = anchor.Parent()
|
||||||
|
}
|
||||||
|
shout := Shout{}
|
||||||
|
|
||||||
|
authorLink := container.Find("a.c-usernameBlock__displayName").First()
|
||||||
|
if authorLink.Length() > 0 {
|
||||||
|
href, _ := authorLink.Attr("href")
|
||||||
|
shout.Author = UserRef{
|
||||||
|
DisplayName: trimText(authorLink.Find("span.js-displayName").First()),
|
||||||
|
AvatarURL: urls.AbsoluteCDN(trimAttr(container.Find("img.comment_useravatar").First(), "src")),
|
||||||
|
}
|
||||||
|
if shout.Author.DisplayName == "" {
|
||||||
|
shout.Author.DisplayName = trimText(authorLink)
|
||||||
|
}
|
||||||
|
if parts := strings.Split(strings.Trim(href, "/"), "/"); len(parts) >= 2 {
|
||||||
|
shout.Author.Name = strings.ToLower(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shout.PostedAt = parsePopupDate(container.Find("comment-date span.popup_date").First())
|
||||||
|
if shout.PostedAt.IsZero() {
|
||||||
|
shout.PostedAt = parsePopupDate(container.Find("span.popup_date").First())
|
||||||
|
}
|
||||||
|
|
||||||
|
body := container.Find("comment-user-text").First()
|
||||||
|
if body.Length() == 0 {
|
||||||
|
body = container.Find(".comment_text, .comment-user-text").First()
|
||||||
|
}
|
||||||
|
shout.BodyHTML = htmlOf(body)
|
||||||
|
|
||||||
|
if shout.Author.DisplayName != "" || shout.BodyHTML != "" {
|
||||||
|
u.Shouts = append(u.Shouts, shout)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch state: the header carries a Watch/Unwatch button when the viewer
|
||||||
|
// is logged in and looking at another user's page. An "/unwatch/" link
|
||||||
|
// means the viewer currently watches this user.
|
||||||
|
if _, unwatch := findWatchLinks(doc, u.Name); unwatch != "" {
|
||||||
|
u.Watched = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Site banner: the <site-banner> element in the page header holds either
|
||||||
|
// the artist's own banner (URL under /art/<name>/, uploaded via
|
||||||
|
// /controls/profilebanner/) or when none is set FA's site-wide promo
|
||||||
|
// banner (URL under /media/banners/).
|
||||||
|
if banner := doc.Find("site-banner img").First(); banner.Length() > 0 {
|
||||||
|
src := urls.AbsoluteCDN(trimAttr(banner, "src"))
|
||||||
|
if src != "" {
|
||||||
|
u.SiteBanner = &SiteBanner{
|
||||||
|
ImageURL: src,
|
||||||
|
IsCustom: strings.Contains(src, "/art/"+u.Name+"/"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstNonEmptySel returns the first selection matching any of the selectors,
|
||||||
|
// or nil if none match. Useful for parser code that needs to tolerate
|
||||||
|
// alternate beta-theme markup.
|
||||||
|
func firstNonEmptySel(doc *goquery.Document, selectors ...string) *goquery.Selection {
|
||||||
|
for _, sel := range selectors {
|
||||||
|
s := doc.Find(sel).First()
|
||||||
|
if s.Length() > 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
243
user_parser_test.go
Normal file
243
user_parser_test.go
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
package fa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// syntheticUserHTML mirrors the real /user/{name}/ markup closely enough to
|
||||||
|
// catch the bugs the fictitious old fixture hid: a logged-in viewer's avatar
|
||||||
|
// in the site nav (which must NOT be picked as the profile avatar), the
|
||||||
|
// `<span class="highlight">Label:</span> value` stats strip, and the
|
||||||
|
// watcher/watching counts that live in the section headers rather than the
|
||||||
|
// stats box.
|
||||||
|
const syntheticUserHTML = `<html><body>
|
||||||
|
<!-- site navigation: this is the logged-in VIEWER, not the profile owner -->
|
||||||
|
<img class="loggedin_user_avatar avatar" alt="Viewer" src="//a.furaffinity.net/0/viewer.gif"/>
|
||||||
|
|
||||||
|
<userpage-nav-header>
|
||||||
|
<userpage-nav-avatar>
|
||||||
|
<a class="current" href="/user/somefurry/"><img alt="somefurry" src="//a.furaffinity.net/123/somefurry.gif"/></a>
|
||||||
|
</userpage-nav-avatar>
|
||||||
|
<userpage-nav-user-details>
|
||||||
|
<div class="top-bar"><username>
|
||||||
|
<div class="c-usernameBlock username-in-nav-bar">
|
||||||
|
<a class="c-usernameBlock__displayName js-displayName-block" href="/user/somefurry/">
|
||||||
|
<span class="js-displayName">SomeFurry</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</username></div>
|
||||||
|
<div class="font-small"><span class="user-title">
|
||||||
|
Artist | <span class="hideonmobile">Registered:</span>
|
||||||
|
<span class="popup_date" data-time="1519896600" title="Mar 1, 2018 09:30 AM">8 years ago</span>
|
||||||
|
</span></div>
|
||||||
|
</userpage-nav-user-details>
|
||||||
|
<userpage-nav-interface-buttons>
|
||||||
|
<a class="button standard samewidth stop" id="watch-button" href="/unwatch/somefurry/?key=abc">Unwatch</a>
|
||||||
|
</userpage-nav-interface-buttons>
|
||||||
|
</userpage-nav-header>
|
||||||
|
|
||||||
|
<div class="userpage-profile"><p>Welcome to my profile.</p></div>
|
||||||
|
|
||||||
|
<section class="userpage-right-column">
|
||||||
|
<div class="userpage-section-right">
|
||||||
|
<div class="section-header"><h2>Stats</h2></div>
|
||||||
|
<div class="section-body"><div class="table">
|
||||||
|
<div class="cell">
|
||||||
|
<span class="highlight">Views:</span> 1,176 <br/>
|
||||||
|
<span class="highlight">Submissions:</span> 1,234<br/>
|
||||||
|
<span class="highlight">Favs:</span> 567
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<span class="highlight">Comments Earned:</span> 85<br/>
|
||||||
|
<span class="highlight">Comments Made:</span> 83<br/>
|
||||||
|
<span class="highlight">Journals:</span> 12
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="userpage-left-column watched-by-block">
|
||||||
|
<div class="userpage-section-left"><div class="section-header">
|
||||||
|
<div class="floatright"><h3><a href="/watchlist/to/somefurry/">View List (Watched by 89)</a></h3></div>
|
||||||
|
<h2>Recent Watchers</h2>
|
||||||
|
</div></div>
|
||||||
|
</section>
|
||||||
|
<section class="userpage-left-column is-watching-block">
|
||||||
|
<div class="userpage-section-left"><div class="section-header">
|
||||||
|
<div class="floatright"><h3><a href="/watchlist/by/somefurry/">View List (Watching 10)</a></h3></div>
|
||||||
|
<h2>Recently Watched</h2>
|
||||||
|
</div></div>
|
||||||
|
</section>
|
||||||
|
</body></html>`
|
||||||
|
|
||||||
|
func TestParseUser_Synthetic(t *testing.T) {
|
||||||
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(syntheticUserHTML))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
u, err := parseUser("SomeFurry", doc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseUser: %v", err)
|
||||||
|
}
|
||||||
|
if u.DisplayName != "SomeFurry" {
|
||||||
|
t.Errorf("DisplayName = %q; want SomeFurry", u.DisplayName)
|
||||||
|
}
|
||||||
|
if u.Name != "somefurry" {
|
||||||
|
t.Errorf("Name = %q; want somefurry (lowercased)", u.Name)
|
||||||
|
}
|
||||||
|
if u.AvatarURL != "https://a.furaffinity.net/123/somefurry.gif" {
|
||||||
|
t.Errorf("AvatarURL = %q; want the profile owner's avatar, not the logged-in viewer's", u.AvatarURL)
|
||||||
|
}
|
||||||
|
if u.Stats.Submissions != 1234 {
|
||||||
|
t.Errorf("Stats.Submissions = %d; want 1234", u.Stats.Submissions)
|
||||||
|
}
|
||||||
|
if u.Stats.Favorites != 567 {
|
||||||
|
t.Errorf("Stats.Favorites = %d; want 567", u.Stats.Favorites)
|
||||||
|
}
|
||||||
|
if u.Stats.Views != 1176 {
|
||||||
|
t.Errorf("Stats.Views = %d; want 1176", u.Stats.Views)
|
||||||
|
}
|
||||||
|
if u.Stats.Comments != 85 {
|
||||||
|
t.Errorf("Stats.Comments = %d; want 85", u.Stats.Comments)
|
||||||
|
}
|
||||||
|
if u.Stats.Journals != 12 {
|
||||||
|
t.Errorf("Stats.Journals = %d; want 12", u.Stats.Journals)
|
||||||
|
}
|
||||||
|
if u.Stats.Watchers != 89 {
|
||||||
|
t.Errorf("Stats.Watchers = %d; want 89", u.Stats.Watchers)
|
||||||
|
}
|
||||||
|
if u.Stats.Watching != 10 {
|
||||||
|
t.Errorf("Stats.Watching = %d; want 10", u.Stats.Watching)
|
||||||
|
}
|
||||||
|
if !u.Watched {
|
||||||
|
t.Error("Watched = false; want true (page shows an Unwatch button)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(u.BioText, "Welcome") {
|
||||||
|
t.Errorf("BioText missing expected content: %q", u.BioText)
|
||||||
|
}
|
||||||
|
if u.Joined.Year() != 2018 {
|
||||||
|
t.Errorf("Joined.Year = %d; want 2018", u.Joined.Year())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUser_RealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "user.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
u, err := parseUser("fixture", doc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseUser(real): %v", err)
|
||||||
|
}
|
||||||
|
// Values below are read directly from testdata/html/user.html. They guard
|
||||||
|
// against FA markup drift a zero/empty here means a selector went stale.
|
||||||
|
if u.DisplayName != "SoXX-TheFennec" {
|
||||||
|
t.Errorf("DisplayName = %q; want SoXX-TheFennec", u.DisplayName)
|
||||||
|
}
|
||||||
|
if u.AvatarURL != "https://a.furaffinity.net/1515442832/soxx-thefennec.gif" {
|
||||||
|
t.Errorf("AvatarURL = %q; want the profile owner's avatar", u.AvatarURL)
|
||||||
|
}
|
||||||
|
if u.Stats.Submissions != 30 {
|
||||||
|
t.Errorf("Stats.Submissions = %d; want 30", u.Stats.Submissions)
|
||||||
|
}
|
||||||
|
if u.Stats.Favorites != 180 {
|
||||||
|
t.Errorf("Stats.Favorites = %d; want 180", u.Stats.Favorites)
|
||||||
|
}
|
||||||
|
if u.Stats.Views != 1176 {
|
||||||
|
t.Errorf("Stats.Views = %d; want 1176", u.Stats.Views)
|
||||||
|
}
|
||||||
|
if u.Stats.Comments != 85 {
|
||||||
|
t.Errorf("Stats.Comments = %d; want 85", u.Stats.Comments)
|
||||||
|
}
|
||||||
|
if u.Stats.Watchers != 50 {
|
||||||
|
t.Errorf("Stats.Watchers = %d; want 50", u.Stats.Watchers)
|
||||||
|
}
|
||||||
|
if u.Stats.Watching != 315 {
|
||||||
|
t.Errorf("Stats.Watching = %d; want 315", u.Stats.Watching)
|
||||||
|
}
|
||||||
|
if u.SiteBanner == nil {
|
||||||
|
t.Fatal("SiteBanner = nil; want the default site banner populated")
|
||||||
|
}
|
||||||
|
if u.SiteBanner.IsCustom {
|
||||||
|
t.Errorf("SiteBanner.IsCustom = true; want false (user.html shows FA's default site banner)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(u.SiteBanner.ImageURL, "/media/banners/") {
|
||||||
|
t.Errorf("SiteBanner.ImageURL = %q; want a /media/banners/ URL", u.SiteBanner.ImageURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// syntheticUserWithCustomBannerHTML reproduces FA's <site-banner> markup for a
|
||||||
|
// user who has uploaded their own profile banner via /controls/profilebanner/.
|
||||||
|
// The image lives under /art/<name>/ rather than /media/banners/, which is the
|
||||||
|
// signal the parser uses to set IsCustom.
|
||||||
|
const syntheticUserWithCustomBannerHTML = `<html><body>
|
||||||
|
<site-banner>
|
||||||
|
<a href="/">
|
||||||
|
<picture>
|
||||||
|
<source media="(max-width: 799px)" srcset="//d.furaffinity.net/art/somefurry/1716929854/profile_banner_mobile.jpg">
|
||||||
|
<source media="(min-width: 800px)" srcset="//d.furaffinity.net/art/somefurry/1716929854/profile_banner.jpg">
|
||||||
|
<img src="//d.furaffinity.net/art/somefurry/1716929854/profile_banner.jpg" alt="Profile Banner image">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</site-banner>
|
||||||
|
|
||||||
|
<userpage-nav-header>
|
||||||
|
<userpage-nav-avatar>
|
||||||
|
<a class="current" href="/user/somefurry/"><img alt="somefurry" src="//a.furaffinity.net/123/somefurry.gif"/></a>
|
||||||
|
</userpage-nav-avatar>
|
||||||
|
<userpage-nav-user-details>
|
||||||
|
<div class="c-usernameBlock"><span class="c-usernameBlock__displayName">SomeFurry</span></div>
|
||||||
|
</userpage-nav-user-details>
|
||||||
|
</userpage-nav-header>
|
||||||
|
</body></html>`
|
||||||
|
|
||||||
|
// TestParseUser_SiteBanner_RealFixture validates the custom-banner case
|
||||||
|
// against a real captured profile (gillpanda, who has uploaded a profile
|
||||||
|
// banner). The fixture is captured by the `fixtures` build-tagged refresh
|
||||||
|
// test using FA_TEST_USER_WITH_BANNER; the test skips cleanly when absent.
|
||||||
|
func TestParseUser_SiteBanner_RealFixture(t *testing.T) {
|
||||||
|
raw := loadFixture(t, "user_with_banner.html")
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read doc: %v", err)
|
||||||
|
}
|
||||||
|
u, err := parseUser("gillpanda", doc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseUser(real): %v", err)
|
||||||
|
}
|
||||||
|
if u.SiteBanner == nil {
|
||||||
|
t.Fatal("SiteBanner = nil; want a populated custom banner")
|
||||||
|
}
|
||||||
|
if !u.SiteBanner.IsCustom {
|
||||||
|
t.Errorf("SiteBanner.IsCustom = false; want true (gillpanda has a custom banner)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(u.SiteBanner.ImageURL, "/art/gillpanda/") {
|
||||||
|
t.Errorf("SiteBanner.ImageURL = %q; want a /art/gillpanda/ URL", u.SiteBanner.ImageURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUser_SiteBanner_Custom(t *testing.T) {
|
||||||
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(syntheticUserWithCustomBannerHTML))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
u, err := parseUser("SomeFurry", doc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseUser: %v", err)
|
||||||
|
}
|
||||||
|
if u.SiteBanner == nil {
|
||||||
|
t.Fatal("SiteBanner = nil; want the custom banner populated")
|
||||||
|
}
|
||||||
|
if !u.SiteBanner.IsCustom {
|
||||||
|
t.Errorf("SiteBanner.IsCustom = false; want true (URL is under /art/<name>/)")
|
||||||
|
}
|
||||||
|
want := "https://d.furaffinity.net/art/somefurry/1716929854/profile_banner.jpg"
|
||||||
|
if u.SiteBanner.ImageURL != want {
|
||||||
|
t.Errorf("SiteBanner.ImageURL = %q; want %q", u.SiteBanner.ImageURL, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user