How to copy nodes and their links from one node tree/material to another - in python?
After some experimenting, I couldn't find any way of doing this with bpy.data or with the operator for making a group, so I decided to write my own:
This operator takes in the name of the node group that should be created and then moves all selected nodes from the actives object active material in a node group with that name. It also copies all the links between the nodes and the property values. It doesn't just copy the input values but also dropdowns and similar options.

Here's the code:

class NODE_OT_MakeGroup(bpy.types.Operator):
    bl_idname = "node.group_make_no_context"
    bl_label = "Make Context Free Group"
    bl_description = "Make group from selected nodes without context"
    bl_options = { "REGISTER", "UNDO" }

    name: bpy.props.StringProperty( default="NodeGroup" )

    @classmethod
    def poll( cls, context ):
        """run operator only if an active object with an active material exists"""

        if context.active_object:
            if context.active_object.active_material:
                if context.active_object.active_material.use_nodes:
                    return True
        return False


    def get_selected_nodes(self,context):
        """returns a list of the selected nodes from the active objects active material"""

        nodes = []
        for node in context.active_object.active_material.node_tree.nodes:
            if node.select:
                nodes.append(node)
        return nodes


    def copy_attributes(self, attributes, old_prop, new_prop):
        """copies the list of attributes from the old to the new prop if the attribute exists"""

        #check if the attribute exists and copy it
        for attr in attributes:
            if hasattr( new_prop, attr ):
                setattr( new_prop, attr, getattr( old_prop, attr ) )


    def get_node_attributes(self, node):
        """returns a list of all propertie identifiers if they shoulnd't be ignored"""

        #all attributes that shouldn't be copied
        ignore_attributes = ( "rna_type", "type", "dimensions", "inputs", "outputs", "internal_links", "select")

        attributes = []
        for attr in node.bl_rna.properties:
            #check if the attribute should be copied and add it to the list of attributes to copy
            if not attr.identifier in ignore_attributes and not attr.identifier.split("_")[0] == "bl":
                attributes.append(attr.identifier)

        return attributes


    def copy_nodes(self, nodes, group):
        """copies all nodes from the given list into the group with their attributes"""

        #the attributes that should be copied for every link
        input_attributes = ( "default_value", "name" )
        output_attributes = ( "default_value", "name" )

        for node in nodes:
            #create a new node in the group and find and copy its attributes
            new_node = group.nodes.new( node.bl_idname )
            node_attributes = self.get_node_attributes( node )
            self.copy_attributes( node_attributes, node, new_node )

            #copy the attributes for all inputs
            for i, inp in enumerate(node.inputs):
                self.copy_attributes( input_attributes, inp, new_node.inputs[i] )

            #copy the attributes for all outputs
            for i, out in enumerate(node.outputs):
                self.copy_attributes( output_attributes, out, new_node.outputs[i] )


    def copy_links(self, context, nodes, group):
        """copies all links between the nodes in the list to the nodes in the group"""

        for node in nodes:
            #find the corresponding node in the created group
            new_node = group.nodes[ node.name ]

            #enumerate over every link in the nodes inputs
            for i, inp in enumerate( node.inputs ):
                for link in inp.links:
                    #find the connected node for the link in the group
                    connected_node = group.nodes[ link.from_node.name ]
                    #connect the group nodes
                    group.links.new( connected_node.outputs[ link.from_socket.name ], new_node.inputs[i] )


    def add_group_nodes(self, group):
        """adds the group input and output node and positions them correctly"""

        #add group input and output
        group_input = group.nodes.new("NodeGroupInput")
        group_output = group.nodes.new("NodeGroupOutput")

        #if there are any nodes in the group, find the mini and maxi x position of all nodes and position the group nodes
        if len(group.nodes) &gt; 0:
            min_pos = 9999999
            max_pos = -9999999

            for node in group.nodes:
                if node.location[0] &lt; min_pos:
                    min_pos = node.location[0]
                elif node.location[0] + node.width &gt; max_pos:
                    max_pos = node.location[0]

            group_input.location = (min_pos - 250, 0)
            group_output.location = (max_pos + 250, 0)


    def execute( self, context ):
        """creates the node group, copies all attributes and links and adds the group input and output"""

        #create new node group
        group = bpy.data.node_groups.new( name=self.name, type="ShaderNodeTree" )
        #get the list of all selected nodes from the active objects active material
        nodes = self.get_selected_nodes(context)

        #copy all nodes from the list to the created group with all their attributes
        self.copy_nodes( nodes, group )
        #copy the links between the nodes to the created groups nodes
        self.copy_links( context, nodes, group )

        #add the group input and output node to the created group
        self.add_group_nodes(group)

        return { "FINISHED" }


I'm sure there are some improvements that could be done to this and of course, it's not a perfect solution because that operator basically already exists, but I think it's the best option while these are not working. I tried it with 2.83 and it seems to work fine for all cases I tested.

EDIT:
Here's a version that was better suited for my personal case. It has an additional parameter to specify a specific material. If it doesn't get a value for that parameter it defaults to the active objects active material. Otherwise, it's the same as the one above:

class NODE_OT_MakeGroup(bpy.types.Operator):
    bl_idname = "node.group_make_no_context"
    bl_label = "Make Context Free Group"
    bl_description = "Make group from selected nodes without context"
    bl_options = { "REGISTER", "UNDO" }

    name: bpy.props.StringProperty( default="NodeGroup" )
    material: bpy.props.StringProperty( default="")

    def get_selected_nodes(self,context):
        """returns a list of the selected nodes from the material"""

        nodes = []
        for node in bpy.data.materials[self.material].node_tree.nodes:
            if node.select:
                nodes.append(node)
        return nodes


    def copy_attributes(self, attributes, old_prop, new_prop):
        """copies the list of attributes from the old to the new prop if the attribute exists"""

        #check if the attribute exists and copy it
        for attr in attributes:
            if hasattr( new_prop, attr ):
                setattr( new_prop, attr, getattr( old_prop, attr ) )


    def get_node_attributes(self, node):
        """returns a list of all propertie identifiers if they shoulnd't be ignored"""

        #all attributes that shouldn't be copied
        ignore_attributes = ( "rna_type", "type", "dimensions", "inputs", "outputs", "internal_links", "select")

        attributes = []
        for attr in node.bl_rna.properties:
            #check if the attribute should be copied and add it to the list of attributes to copy
            if not attr.identifier in ignore_attributes and not attr.identifier.split("_")[0] == "bl":
                attributes.append(attr.identifier)

        return attributes


    def copy_nodes(self, nodes, group):
        """copies all nodes from the given list into the group with their attributes"""

        #the attributes that should be copied for every link
        input_attributes = ( "default_value", "name" )
        output_attributes = ( "default_value", "name" )

        for node in nodes:
            #create a new node in the group and find and copy its attributes
            new_node = group.nodes.new( node.bl_idname )
            node_attributes = self.get_node_attributes( node )
            self.copy_attributes( node_attributes, node, new_node )

            #copy the attributes for all inputs
            for i, inp in enumerate(node.inputs):
                self.copy_attributes( input_attributes, inp, new_node.inputs[i] )

            #copy the attributes for all outputs
            for i, out in enumerate(node.outputs):
                self.copy_attributes( output_attributes, out, new_node.outputs[i] )


    def copy_links(self, context, nodes, group):
        """copies all links between the nodes in the list to the nodes in the group"""

        for node in nodes:
            #find the corresponding node in the created group
            new_node = group.nodes[ node.name ]

            #enumerate over every link in the nodes inputs
            for i, inp in enumerate( node.inputs ):
                for link in inp.links:
                    #find the connected node for the link in the group
                    connected_node = group.nodes[ link.from_node.name ]
                    #connect the group nodes
                    group.links.new( connected_node.outputs[ link.from_socket.name ], new_node.inputs[i] )


    def add_group_nodes(self, group):
        """adds the group input and output node and positions them correctly"""

        #add group input and output
        group_input = group.nodes.new("NodeGroupInput")
        group_output = group.nodes.new("NodeGroupOutput")

        #if there are any nodes in the group, find the min and maxi x position of all nodes and position the group nodes
        if len(group.nodes) &gt; 0:
            min_pos = 9999999
            max_pos = -9999999

            for node in group.nodes:
                if node.location[0] &lt; min_pos:
                    min_pos = node.location[0]
                elif node.location[0] + node.width &gt; max_pos:
                    max_pos = node.location[0]

            group_input.location = (min_pos - 250, 0)
            group_output.location = (max_pos + 250, 0)


    def execute( self, context ):
        """creates the node group, copies all attributes and links and adds the group input and output"""

        #set material if not given
        if not self.material:
            self.material = bpy.context.active_object.active_material.name

        #create new node group
        group = bpy.data.node_groups.new( name=self.name, type="ShaderNodeTree" )
        #get the list of all selected nodes from the active objects active material
        nodes = self.get_selected_nodes(context)

        #copy all nodes from the list to the created group with all their attributes
        self.copy_nodes( nodes, group )
        #copy the links between the nodes to the created groups nodes
        self.copy_links( context, nodes, group )

        #add the group input and output node to the created group
        self.add_group_nodes(group)

        return { "FINISHED" }